From 05adb745ad0c713e93e76c55eb09e19cab028f63 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:15:51 -0700 Subject: [PATCH 01/37] Use the Windows App SDK dotnet new templates instead of ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows App SDK template pack now ships first-class Reactor templates (microsoft/WindowsAppSDK#6620): `reactor`, `reactor-mvu`, `reactor-navview`, and `reactor-tabview`. Switch to those as the supported scaffolding path. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` pack (`dotnet new reactorapp`, unpackaged) is kept — `mur pack-local` still builds it and the release workflow still publishes it — but nothing installs it automatically any more. The big user-visible change is that scaffolded apps are now **packaged** (single-project MSIX) rather than unpackaged, so `dotnet run` launches with package identity and needs Developer Mode. Docs are updated accordingly. - bootstrap.ps1: step 5 installs the Windows App SDK pack via `mur templates install`. New `-WinAppSdkTemplatesSource` (local feed, for testing an unpublished build), `-WinAppSdkTemplatesVersion`, and `-SkipTemplates`. - New `mur templates install|status`, shared by bootstrap and `mur upgrade`. - `mur doctor` now checks for the Windows App SDK pack; the legacy local template nupkg drops from FAIL to WARN. Two hazards found and handled while validating: - `dotnet new install ` has no `--prerelease` switch and resolves stable-only, so installing the bare package id fails outright while the pack is prerelease-only (it is today, at 0.0.6-alpha). We resolve the newest published version ourselves — newest stable, else newest prerelease — and install an explicit `::`. - `dotnet new install --force` uninstalls the existing package *before* downloading the replacement, so a failed install leaves the machine with no templates at all. Observed for real: bare id + `--force` + no stable version uninstalled a working prerelease and then failed with exit 103. `--force` is now only used to replace an install with a version already known to exist, and `Install` leaves an existing install untouched when it cannot resolve a target. Tests: version-selection rules (prefer stable, else newest prerelease ordered numerically), local-folder version discovery, a source-level guard against re-pairing `--force` with an unresolved spec, and bootstrap guards that it installs the new pack and does not install the legacy one. All mutation-checked. Full suite green (13,380). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 55 ++-- .github/workflows/release.yml | 4 +- README.md | 19 +- SKILL.md | 25 +- bootstrap.ps1 | 173 +++++++---- docs/_pipeline/templates/dev-tooling.md.dt | 3 +- .../_pipeline/templates/getting-started.md.dt | 181 +++++++---- docs/_pipeline/templates/packaging.md.dt | 114 ++++--- docs/contributing/release-runbook.md | 2 +- docs/guide/dev-tooling.md | 3 +- docs/guide/getting-started.md | 180 +++++++---- docs/guide/packaging.md | 100 ++++-- plugins/reactor/agents/reactor-dev.agent.md | 12 +- .../skills/reactor-getting-started/SKILL.md | 14 +- src/Reactor.Cli/Doctor/DoctorCommand.cs | 59 ++-- src/Reactor.Cli/Program.cs | 10 +- src/Reactor.Cli/Templates/TemplatesCommand.cs | 124 ++++++++ .../Templates/WinAppSdkTemplates.cs | 285 ++++++++++++++++++ src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 77 ++--- tests/Reactor.Tests/TemplateMetadataTests.cs | 71 ++++- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 205 +++++++++++++ tools/Templates/README.md | 19 ++ 22 files changed, 1355 insertions(+), 380 deletions(-) create mode 100644 src/Reactor.Cli/Templates/TemplatesCommand.cs create mode 100644 src/Reactor.Cli/Templates/WinAppSdkTemplates.cs create mode 100644 tests/Reactor.Tests/WinAppSdkTemplatesTests.cs diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 7347ea693..bfe9e97fb 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -2,7 +2,7 @@ name: Bootstrap # Exercises bootstrap.ps1 end-to-end on a fresh windows-latest runner to # validate the source-checkout developer flow (`git clone` → `bootstrap.ps1` -# → `dotnet new reactorapp`). The script is invoked non-interactively via +# → `dotnet new reactor`). The script is invoked non-interactively via # `-InstallWinAppSdk -SkipPlugin`, which also exercises the winget install # path for the Windows App Runtime when the runner image doesn't already # have it. @@ -132,22 +132,42 @@ jobs: if ($cliPkgs.Count -eq 0) { throw "No Microsoft.UI.Reactor.Cli.*.nupkg found in local-nupkgs/" } Write-Host " [ok] CLI tool nupkg: $($cliPkgs[0].Name)" - - name: Verify dotnet new reactorapp template registered + - name: Verify dotnet new reactor templates registered shell: pwsh run: | - $listing = dotnet new list reactorapp 2>&1 + # Reactor's app templates ship in the Windows App SDK `dotnet new` + # pack, which bootstrap.ps1 §5 installs via `mur templates install`. + $listing = dotnet new list reactor 2>&1 $rc = $LASTEXITCODE Write-Host $listing - if ($rc -ne 0) { throw "dotnet new list reactorapp exited $rc" } + if ($rc -ne 0) { throw "dotnet new list reactor exited $rc" } # `dotnet new list` writes a multi-line table; PowerShell stores the # output as a [string[]]. -match / -notmatch against an array filter - # element-wise, so `-notmatch 'reactorapp'` returns the non-matching + # element-wise, so `-notmatch 'reactor'` returns the non-matching # lines (header, separator) which evaluates truthy even when the # template is present. Join + match to do a whole-output substring # check instead. - if (($listing -join "`n") -notmatch 'reactorapp') { - throw "reactorapp template not found in `dotnet new list` output" + $joined = $listing -join "`n" + foreach ($shortName in @('reactor', 'reactor-mvu', 'reactor-navview', 'reactor-tabview')) { + if ($joined -notmatch [regex]::Escape($shortName)) { + throw "$shortName template not found in ``dotnet new list reactor`` output" + } + Write-Host " [ok] $shortName" + } + # The pack itself must be the Windows App SDK one, not our legacy + # in-repo ProjectTemplates package. + $packages = dotnet new uninstall 2>&1 | Out-String + if ($packages -notmatch 'Microsoft\.WindowsAppSDK\.WinUI\.CSharp\.Templates') { + throw "Microsoft.WindowsAppSDK.WinUI.CSharp.Templates is not installed" + } + Write-Host " [ok] Microsoft.WindowsAppSDK.WinUI.CSharp.Templates registered" + # bootstrap must NOT install the legacy pack any more. A stray install + # would silently hand new developers the unpackaged `reactorapp` + # template the docs no longer describe. + if ($packages -match 'Microsoft\.UI\.Reactor\.ProjectTemplates') { + throw "bootstrap installed the legacy Microsoft.UI.Reactor.ProjectTemplates pack; it should only be packed, not installed" } + Write-Host " [ok] legacy Microsoft.UI.Reactor.ProjectTemplates not installed" - name: Scaffold a TestApp and restore against the local feed shell: pwsh @@ -168,9 +188,11 @@ jobs: # Bootstrap validates the local 0.0.0-local feed produced by # mur pack-local. The template's normal default may point at a # not-yet-published public preview while a release-prep PR is in - # flight, so opt into the local package version explicitly here. - dotnet new reactorapp -n TestApp --MSUIReactorVersion 0.0.0-local - if ($LASTEXITCODE -ne 0) { throw "dotnet new reactorapp exited $LASTEXITCODE" } + # flight, so opt into the local package version explicitly here + # (`--reactor-version` is the Windows App SDK template's knob; + # the legacy `reactorapp` template called it --MSUIReactorVersion). + dotnet new reactor -n TestApp --reactor-version 0.0.0-local + if ($LASTEXITCODE -ne 0) { throw "dotnet new reactor exited $LASTEXITCODE" } if (-not (Test-Path 'TestApp/TestApp.csproj')) { throw "TestApp/TestApp.csproj not produced" } dotnet restore TestApp/TestApp.csproj --nologo -v:m @@ -179,14 +201,12 @@ jobs: Pop-Location } - - name: Build TestApp (template ships WindowsAppSDKSelfContained=true) + - name: Build TestApp (packaged single-project MSIX) shell: pwsh run: | - # The scaffolded template (tools/Templates/templates/WinUIApp-CSharp) - # explicitly sets WindowsAppSDKSelfContained=true so end users get a - # standalone deployable. SelfContained=true requires a concrete arch - # to embed the runtime under — AnyCPU is rejected by the - # SelfContained target. Pass the host arch explicitly. + # The Windows App SDK Reactor templates scaffold a *packaged* + # (single-project MSIX) app, which requires a concrete architecture — + # AnyCPU is rejected. Pass the host arch explicitly. $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'ARM64' } else { 'x64' } dotnet build TestProjects/TestApp/TestApp.csproj ` -c Release ` @@ -198,7 +218,8 @@ jobs: shell: pwsh run: | # `mur upgrade` should succeed against an already-bootstrapped tree: - # re-pack, re-install template (uninstall-first), refresh plugin. + # re-pack, ensure the `dotnet new reactor` templates are installed, + # refresh plugin. $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" mur upgrade --skip-plugin if ($LASTEXITCODE -ne 0) { throw "mur upgrade exited $LASTEXITCODE" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f3f85bc5..b809099da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,7 +180,7 @@ jobs: - name: Pack Devtools NuGet # Microsoft.UI.Reactor.Devtools is referenced by the scaffolded - # `dotnet new reactorapp` csproj's Debug-only ItemGroup (the devtools menu + the + # `dotnet new reactor` csproj's Debug-only ItemGroup (the devtools menu + the # Reactor Visual Studio embedded-preview extension both need it). Ship it alongside # the framework so the template's restore succeeds against the published feed. # Version is locked to the framework version (spec 022). Packs from the AnyCPU @@ -372,7 +372,7 @@ jobs: ### `Microsoft.UI.Reactor.Devtools.${{ steps.version.outputs.version }}.nupkg` — optional devtools host (debug + VS preview) - Referenced by the scaffolded `dotnet new reactorapp` csproj in a + Referenced by the scaffolded `dotnet new reactor` csproj in a Debug-only `ItemGroup`, paired with `RuntimeHostConfigurationOption Reactor.DevtoolsSupport=true`. Required to light up the right-click devtools menu in Debug F5 launches and for the Reactor VS embedded diff --git a/README.md b/README.md index 476e533ad..e619a21e6 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,18 @@ Many of the experiments in this repo — the charting stack, accessibility valid ## Quick start -Reactor ships the public preview package [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) on NuGet.org; see the [NuGet page](https://www.nuget.org/packages/Microsoft.UI.Reactor) or [GitHub Releases](https://github.com/microsoft/microsoft-ui-reactor/releases) for the current version. The project template is still installed from source for now; `bootstrap.ps1` installs the `mur` CLI, packs/registers the local `reactorapp` template, and that template references the public preview package by default. +Reactor ships the public preview package [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) on NuGet.org; see the [NuGet page](https://www.nuget.org/packages/Microsoft.UI.Reactor) or [GitHub Releases](https://github.com/microsoft/microsoft-ui-reactor/releases) for the current version. The project templates ship in the official Windows App SDK `dotnet new` pack, so building an app needs no source checkout: + +```powershell +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new reactor -n MyApp +cd MyApp +dotnet run +``` + +Scaffolded apps are packaged (single-project MSIX), so `dotnet run` launches them with full package identity — the F5 equivalent. `reactor-mvu`, `reactor-navview`, and `reactor-tabview` start from richer shells. Requires Developer Mode (Settings → System → For developers). + +### Contributing to Reactor itself ```powershell git clone https://github.com/microsoft/microsoft-ui-reactor.git @@ -78,7 +89,7 @@ cd microsoft-ui-reactor # calling the bootstrap system under the correct PowerShell version you're on & (Get-Process -Id $PID).Path -ExecutionPolicy Bypass -File .\bootstrap.ps1 -dotnet new reactorapp -n MyApp +dotnet new reactor -n MyApp cd MyApp dotnet run -p:Platform=x64 ``` @@ -97,7 +108,9 @@ dotnet run -p:Platform=x64 > causes `WindowsAppSDKSelfContained` errors. This applies to `dotnet build`, > `dotnet run`, and `mur check` invocations alike. -`bootstrap.ps1` packs `mur` as a `dotnet tool` global install (cross-shell PATH, no per-arch `$env:Path` edits), packs local framework snapshots plus project templates into `local-nupkgs/`, registers the `dotnet new reactorapp` template, and installs the Reactor agent plugin under `~/.claude/plugins/reactor`. Apps created by the template reference the public `Microsoft.UI.Reactor` package from NuGet.org by default; pass `--MSUIReactorVersion 0.0.0-local` when you intentionally want a scaffolded app to consume the local source-built package instead. The optional `Microsoft.UI.Reactor.Advanced` and `Microsoft.UI.Reactor.Devtools` sibling packages are version-matched to the framework package when published. Re-run `bootstrap.ps1` (or `mur upgrade` for a lighter refresh) after `git pull` when you want updated local templates or CLI/plugin bits. Verify a working developer install with `mur doctor`. +`bootstrap.ps1` packs `mur` as a `dotnet tool` global install (cross-shell PATH, no per-arch `$env:Path` edits), packs local framework snapshots into `local-nupkgs/`, installs the Windows App SDK `dotnet new` template pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, which provides `dotnet new reactor`), and installs the Reactor agent plugin under `~/.claude/plugins/reactor`. Apps created by the template reference the public `Microsoft.UI.Reactor` package from NuGet.org by default; pass `--reactor-version 0.0.0-local` when you intentionally want a scaffolded app to consume the local source-built package instead. To test an unpublished build of the template pack, run `./bootstrap.ps1 -WinAppSdkTemplatesSource `. The optional `Microsoft.UI.Reactor.Advanced` and `Microsoft.UI.Reactor.Devtools` sibling packages are version-matched to the framework package when published. Re-run `bootstrap.ps1` (or `mur upgrade` for a lighter refresh) after `git pull` when you want updated CLI/plugin bits. Verify a working developer install with `mur doctor`. + +> **Legacy template.** This repo still builds and publishes `Microsoft.UI.Reactor.ProjectTemplates`, which provides the older **unpackaged** `dotnet new reactorapp` template. `bootstrap.ps1` no longer installs it — run `dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg` if you specifically need that shape. On managed Microsoft machines where the public npm or NuGet registries are blocked, bootstrap automatically detects an existing `packagefeedproxy.microsoft.io` registry in the user's `~/.npmrc` and NuGet.Config, verifies unauthenticated package access, and uses it only for the bootstrap process. Public contributors keep the public defaults. Nonstandard mirrors can be selected explicitly with `-NpmRegistry ` and `-NuGetConfig `; credentials remain in user configuration and are never written to the repository. Package feed URLs must use HTTPS (except loopback development feeds) and cannot embed credentials, query strings, or fragments. The npm mirror must permit direct package downloads because the Copilot SDK's MSBuild download task cannot forward npm credentials. diff --git a/SKILL.md b/SKILL.md index 4d8022eba..d52316426 100644 --- a/SKILL.md +++ b/SKILL.md @@ -217,12 +217,13 @@ In selfhost the version is `0.0.0-local` (produced by `mur pack-local` — see "Which mode are you in?" above). Outside the source clone, replace it with whatever Microsoft.UI.Reactor version you depend on. -**After `dotnet new reactorapp -n `, the workspace contains -exactly two source files: `App.cs` (entry point + initial component) -and `.csproj`.** There is no `Program.cs` and no -`GlobalUsings.cs` — modify `App.cs` in place. The `.csproj` does -**not** enable implicit usings; `App.cs` has its own `using` -directives at the top — the canonical set (System + Reactor + +**After `dotnet new reactor -n `, the entry point is `App.cs`** +(entry point + initial component), next to `.csproj`. The template +also emits packaging scaffolding you normally don't touch: +`Package.appxmanifest`, `app.manifest`, `Assets/`, and +`Properties/launchSettings.json` + `Properties/PublishProfiles/`. There is no +`Program.cs` and no `GlobalUsings.cs` — modify `App.cs` in place. `App.cs` has +its own `using` directives at the top — the canonical set (System + Reactor + Reactor.Core + Reactor.Layout + Xaml + Xaml.Controls + static Factories) — which is the only place you add new namespaces (e.g. `using System.Linq;` when you reach for `.Select(...)`). Don't probe the `.csproj` after @@ -230,6 +231,11 @@ scaffolding unless you're adding a `PackageReference` or changing a property — `Restore succeeded.` in the scaffold stdout is the only confirmation you need. +Apps from `dotnet new reactor` are **packaged** (single-project MSIX), so +`dotnet run` launches them with package identity and needs Developer Mode on. +The other short names are `reactor-mvu`, `reactor-navview`, and +`reactor-tabview`. + **Verify your edits with `mur check`** before declaring done. From the project directory: `mur check` (no arguments) runs `dotnet build` and emits one compressed line per diagnostic with a `→ try:` suggestion @@ -258,8 +264,11 @@ next to it pointing at the clone's `local-nupkgs/` (absolute path): Inside the clone you don't need this — the repo-level `nuget.config` already configures the feed. -`WindowsPackageType` MUST be `None` (unpackaged, no App.xaml). `UseWinUI` -MUST be `true`. No XAML files of any kind. +For a **hand-authored** csproj like the one above, `WindowsPackageType` MUST be +`None` (unpackaged, no App.xaml) and `UseWinUI` MUST be `true`. Apps scaffolded +by `dotnet new reactor` are packaged instead and omit `WindowsPackageType` +entirely — leave their packaging properties alone. Either way: no XAML files of +any kind. ### Required imports diff --git a/bootstrap.ps1 b/bootstrap.ps1 index e2d110864..ea121e52a 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -5,9 +5,10 @@ .DESCRIPTION Builds the `mur` CLI, installs it as a dotnet global tool, packs the - framework + ProjectTemplates into local-nupkgs/, installs the - `dotnet new reactorapp` template, and (optionally) drops the Claude - Code plugin under ~/.claude/plugins/reactor. + framework + ProjectTemplates into local-nupkgs/, installs the Windows + App SDK `dotnet new` template pack (which ships `dotnet new reactor`), + and (optionally) drops the Claude Code plugin under + ~/.claude/plugins/reactor. Idempotent — safe to re-run after `git pull` to refresh everything. For a less heavyweight refresh (mur stays put), run `mur upgrade`. @@ -61,6 +62,24 @@ NuGet.Config if it is reachable; otherwise the repo's public config remains in effect. +.PARAMETER WinAppSdkTemplatesSource + Extra NuGet source (local folder or feed URL) to resolve the Windows App + SDK `dotnet new` template pack from. Use this to test an unpublished build + of `Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — point it at the + `dotnet pack` output of a WindowsAppSDK checkout. Layered on top of the + configured sources via `dotnet new install --add-source`. + +.PARAMETER WinAppSdkTemplatesVersion + Pin the Windows App SDK template pack to an explicit version. By default + bootstrap resolves the newest published version — the newest stable when one + exists, otherwise the newest prerelease — so this is only needed to hold the + templates at a known-good version. + +.PARAMETER SkipTemplates + Skip installing the Windows App SDK `dotnet new` template pack. The rest of + the bootstrap (mur, framework nupkgs, plugin, VS extension) still completes; + you just won't get `dotnet new reactor` from this run. + .PARAMETER Verbose Common parameter (enabled by [CmdletBinding]). Surfaces extra diagnostic output at every decision point: detected SDK list, @@ -86,6 +105,12 @@ ./bootstrap.ps1 -Verbose Print extra `VERBOSE:` diagnostics at every decision point. Useful for debugging install failures or unexpected branch behavior. + +.EXAMPLE + ./bootstrap.ps1 -WinAppSdkTemplatesSource ..\WindowsAppSDK\localpackages + Resolve the `dotnet new reactor` template pack from a local folder + instead of NuGet.org — the flow for testing an unpublished + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates build. #> [CmdletBinding()] param( @@ -97,7 +122,10 @@ param( [switch]$NoWinAppSdk, [switch]$SkipWinAppCli, [string]$NpmRegistry, - [string]$NuGetConfig + [string]$NuGetConfig, + [string]$WinAppSdkTemplatesSource, + [string]$WinAppSdkTemplatesVersion, + [switch]$SkipTemplates ) if ($InstallWinAppSdk -and $NoWinAppSdk) { @@ -273,7 +301,7 @@ function Get-VsExtensionSkipReason { # The repo defaults WindowsAppSDKSelfContained=false (see # Directory.Build.props) so samples and perf benches share a single # machine-wide Microsoft.WindowsAppRuntime install rather than bundling a -# copy of the runtime into every build output. The scaffolded template +# copy of the runtime into every build output. The legacy in-repo template # (tools/Templates/templates/WinUIApp-CSharp) and the AOT-publish trim # proofs (tests/aot_trim_proof/*) keep =true explicitly so their build # output stays a standalone deployable. @@ -468,11 +496,12 @@ Write-Step 'Packing local Microsoft.UI.Reactor + ProjectTemplates (`mur pack-loc # project directly (works for -SkipMurInstall too). # # `--framework-version latest` stamps the newest *published* Microsoft.UI.Reactor -# into the scaffolded `reactorapp` template's , so `dotnet new -# reactorapp` in this clone tracks the current release automatically instead of a -# hand-maintained default. Best-effort: if NuGet is unreachable it falls back to -# the template's built-in default. Pass `--MSUIReactorVersion 0.0.0-local` to a -# scaffold to consume this local source build instead. +# into the legacy `reactorapp` template's , so the +# ProjectTemplates nupkg this produces tracks the current release automatically +# instead of a hand-maintained default. (Bootstrap no longer installs that +# template — step 5 installs the Windows App SDK pack instead — but the nupkg is +# still built here and published from the release workflow.) Best-effort: if +# NuGet is unreachable it falls back to the template's built-in default. $packLocalExit = 0 Invoke-ReactorWithRestoreEnvironment ` -NuGetConfig $effectiveNuGetConfig ` @@ -501,51 +530,90 @@ Invoke-ReactorWithRestoreEnvironment ` if ($packLocalExit -ne 0) { Fail 'mur pack-local failed' } # --------------------------------------------------------------------------- -# 5. Install the `dotnet new reactorapp` template +# 5. Install the Windows App SDK `dotnet new` templates (ships `reactor`) # --------------------------------------------------------------------------- -Write-Step 'Installing `dotnet new reactorapp` template' - -$templateNupkg = Join-Path $feed 'Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg' -if (-not (Test-Path $templateNupkg)) { - Fail "Template nupkg not produced at $templateNupkg" -} +# Reactor's app templates ship inside the Windows App SDK template pack +# (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), next to the WinUI 3 XAML +# ones. That pack owns the `reactor`, `reactor-mvu`, `reactor-navview`, and +# `reactor-tabview` short names, and the apps it scaffolds are **packaged** +# (single-project MSIX) — so `dotnet run` launches them with package identity, +# equivalent to F5 in Visual Studio. +# +# This repo still builds and publishes its own legacy +# `Microsoft.UI.Reactor.ProjectTemplates` pack (`dotnet new reactorapp`, +# unpackaged) from tools/Templates/ — `mur pack-local` above just packed it +# into local-nupkgs/ — but bootstrap deliberately no longer *installs* it, so a +# fresh clone gets the officially supported templates by default. To opt back +# into the legacy unpackaged shape, install it by hand: +# dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +if ($SkipTemplates) { + Write-Host '' + Write-Host ' Skipping `dotnet new` template install (per -SkipTemplates).' -ForegroundColor Yellow +} else { + Write-Step 'Installing Windows App SDK `dotnet new` templates (`dotnet new reactor`)' + + $wasdkTemplatePackageId = 'Microsoft.WindowsAppSDK.WinUI.CSharp.Templates' + + # Route through `mur templates install` so bootstrap, `mur upgrade`, and + # `mur doctor` all share one implementation of "which version, from where". + # It resolves the newest published version (newest stable, else newest + # prerelease) and installs an explicit `::` — `dotnet new + # install` has no --prerelease switch, so a bare package id resolves + # stable-only and fails outright while the pack is prerelease-only. + $murTemplateArgs = @('templates', 'install') + if ($WinAppSdkTemplatesSource) { + # Resolve a local folder to an absolute path so it survives the + # working-directory change inside `dotnet new install`. + $resolvedTemplateSource = $WinAppSdkTemplatesSource + if (Test-Path -LiteralPath $resolvedTemplateSource) { + $resolvedTemplateSource = (Resolve-Path -LiteralPath $resolvedTemplateSource).Path + } + Write-Dbg "Template source: $resolvedTemplateSource" + $murTemplateArgs += @('--source', $resolvedTemplateSource) + } + if ($WinAppSdkTemplatesVersion) { + Write-Dbg "Template version pin: $WinAppSdkTemplatesVersion" + $murTemplateArgs += @('--version', $WinAppSdkTemplatesVersion) + } -# Uninstall first so the template engine drops its cached copy by id — -# otherwise the previous install can win against a same-version repack. -# `dotnet new uninstall` (no args) lists installed template packages; skip the -# uninstall on first run when our package isn't there yet (else the non-zero -# exit code becomes a terminating error under $ErrorActionPreference = 'Stop' -# in PS 7.4+, which `2>$null` doesn't intercept). -Write-Dbg "Probing installed templates via 'dotnet new uninstall' (no args)" -if ($script:VerboseOn) { - # Capture the full listing so we can both echo each line as a debug - # breadcrumb AND substring-match for our template id below. - $installedTemplates = & dotnet new uninstall 2>&1 | Out-String - foreach ($line in ($installedTemplates -split "`r?`n")) { - if ($line.Trim()) { Write-Dbg " templates> $line" } + $templatesExit = 0 + Invoke-ReactorWithRestoreEnvironment ` + -NuGetConfig $effectiveNuGetConfig ` + -NuGetSource $effectiveNuGetSource ` + -ExitCode ([ref]$templatesExit) ` + -Action { + $murResolved = Get-Command mur -ErrorAction SilentlyContinue + if ($murResolved) { + Write-Dbg "Using installed mur at $($murResolved.Source)" + & mur @murTemplateArgs + } else { + Write-Dbg "mur not on PATH; falling back to 'dotnet run' against Reactor.Cli source" + $murRestoreArgs = Get-ReactorRestoreArguments ` + -NuGetConfig $effectiveNuGetConfig ` + -NuGetSource $effectiveNuGetSource ` + -NpmRegistry $(if ($npmSelection) { $npmSelection.Registry } else { $null }) + & dotnet run ` + --project (Join-Path $repoRoot 'src\Reactor.Cli\Reactor.Cli.csproj') ` + -c $Configuration ` + "-p:Platform=$hostArch" ` + --nologo ` + @murRestoreArgs ` + -- @murTemplateArgs + } } - $hasReactorTemplate = $installedTemplates -match 'Microsoft\.UI\.Reactor\.ProjectTemplates' -} else { - # Quiet path: stream through Select-String -Quiet so we never materialize - # the multi-KB listing for what is, semantically, a single boolean test. - $hasReactorTemplate = [bool](& dotnet new uninstall 2>&1 | - Select-String -SimpleMatch 'Microsoft.UI.Reactor.ProjectTemplates' -Quiet) -} -if ($hasReactorTemplate) { - Write-Dbg "Existing Microsoft.UI.Reactor.ProjectTemplates detected; uninstalling stale copy" - if ($script:VerboseOn) { - & dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates - } else { - & dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates | Out-Null + if ($templatesExit -ne 0) { + Fail (@( + "Installing $wasdkTemplatePackageId failed.", + " The Reactor templates ship in the Windows App SDK template pack.", + " - To install an unpublished build, re-run with:", + " ./bootstrap.ps1 -WinAppSdkTemplatesSource ", + " - To pin a specific version:", + " ./bootstrap.ps1 -WinAppSdkTemplatesVersion ", + " - To skip this step entirely: ./bootstrap.ps1 -SkipTemplates" + ) -join [Environment]::NewLine) } - if ($LASTEXITCODE -ne 0) { Fail '`dotnet new uninstall` failed' } -} else { - Write-Dbg "No prior install of Microsoft.UI.Reactor.ProjectTemplates; skipping uninstall (first-run path)" + Write-Ok '`dotnet new reactor` templates registered' } -Write-Dbg "dotnet new install $templateNupkg" -& dotnet new install $templateNupkg -if ($LASTEXITCODE -ne 0) { Fail '`dotnet new install` failed' } -Write-Ok 'reactorapp template registered' # --------------------------------------------------------------------------- # 6. Claude Code plugin (optional) @@ -679,10 +747,13 @@ Write-Host '' Write-Host 'Bootstrap complete.' -ForegroundColor Green Write-Host '' Write-Host 'Next:' -Write-Host ' dotnet new reactorapp -n MyApp' +Write-Host ' dotnet new reactor -n MyApp' Write-Host ' cd MyApp' Write-Host ' dotnet run' Write-Host '' +Write-Host 'Other Reactor templates: reactor-mvu, reactor-navview, reactor-tabview' +Write-Host ' dotnet new list reactor' +Write-Host '' Write-Host 'Other useful commands:' Write-Host ' mur doctor verify your install' Write-Host ' mur upgrade refresh local packages + plugin after `git pull`' diff --git a/docs/_pipeline/templates/dev-tooling.md.dt b/docs/_pipeline/templates/dev-tooling.md.dt index 1b7aabbf8..111d248a0 100644 --- a/docs/_pipeline/templates/dev-tooling.md.dt +++ b/docs/_pipeline/templates/dev-tooling.md.dt @@ -157,7 +157,8 @@ subcommands map one-to-one to the workflows below. | `mur loc` | Run the localization pipeline (extract strings, validate `.resw`, generate manifests) | `mur loc extract` | | `mur devtools` | Start the MCP server for VS Code or agent integration | `mur devtools serve` | | `mur check` | Repo-health checks (cref validity, namespace policy, "did you mean" suggestions) | `mur check` | -| `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; the app template defaults to the public Reactor preview unless `--MSUIReactorVersion` is supplied | `mur pack-local` | +| `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; scaffolded apps default to the public Reactor preview unless `--reactor-version` is supplied | `mur pack-local` | +| `mur templates install` / `mur templates status` | Install or check the Windows App SDK `dotnet new` pack that provides `dotnet new reactor` (resolves prereleases that a bare `dotnet new install` can't reach) | `mur templates install` | `mur docs compile` is the workflow you reach for most often. See [the doc-pipeline contributor guide](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/contributing/doc-pipeline.md) diff --git a/docs/_pipeline/templates/getting-started.md.dt b/docs/_pipeline/templates/getting-started.md.dt index 83217c844..e68ca9cb6 100644 --- a/docs/_pipeline/templates/getting-started.md.dt +++ b/docs/_pipeline/templates/getting-started.md.dt @@ -31,17 +31,55 @@ the rest of the docset elaborates. > **Public preview package available.** Reactor ships `Microsoft.UI.Reactor` -> `{{reactorVersion}}` on NuGet.org. The project template package is still -> installed from source for now; `bootstrap.ps1` installs `mur`, packs/registers -> the local `reactorapp` template, and stamps generated apps to reference the -> public preview package by default. Broader signed distribution is tracked in +> `{{reactorVersion}}` on NuGet.org, and the project templates ship in the +> official Windows App SDK `dotnet new` pack +> (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`) — so `dotnet new reactor` +> works from a plain .NET SDK install, no source checkout required. +> `bootstrap.ps1` is for *contributors*: it installs `mur`, packs source-built +> framework snapshots, and registers those same templates. Broader signed +> distribution is tracked in > [spec 022](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/specs/022-packaging-and-distribution.md). Reactor is a declarative UI framework for building native Windows apps in pure C#. No XAML, no data binding, no view models. You describe your UI as a function of state and Reactor keeps the screen in sync. -## Setup (one-time) +## Setup + +If you just want to build an app, install the template pack and go — you do not +need to clone this repo: + +```powershell +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new reactor -n MyApp +cd MyApp +dotnet run +``` + +That gives you four starting points: + +| Template | What it scaffolds | +|---|---| +| `reactor` | Blank Reactor app. Start here if unsure. | +| `reactor-mvu` | Model-View-Update counter built on `UseReducer` | +| `reactor-navview` | `NavigationView` shell with multiple pages | +| `reactor-tabview` | `TabView` shell whose tabs live in the title bar | + +Scaffolded apps are **packaged** (single-project MSIX), so `dotnet run` +registers a loose-layout package and launches the app with full package +identity — the same thing F5 does in Visual Studio. That means features +requiring identity (notifications, background tasks, Windows AI APIs) work out +of the box. See [Packaging](packaging.md) to switch to an unpackaged shape. + +> **Developer Mode must be on** for `dotnet run` to register the loose-layout +> package: Settings → System → For developers → Developer Mode → On. + +Pin package versions at scaffold time with `--reactor-version` (the +`Microsoft.UI.Reactor` package) or `--wasdk-version` (the Windows App SDK). + +## Contributor setup (one-time) + +Working *on* Reactor rather than with it? Clone and bootstrap: ```powershell git clone https://github.com/microsoft/microsoft-ui-reactor.git @@ -49,30 +87,27 @@ cd microsoft-ui-reactor ./bootstrap.ps1 ``` -That's it. `bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global -install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs -`mur pack-local` to produce local source-built framework snapshots and the -matching `ProjectTemplates` nupkg, registers the `dotnet new reactorapp` -template, and drops the Reactor agent plugin under `~/.claude/plugins/reactor` -(symlink when allowed, copy otherwise). Apps created from that template reference -`Microsoft.UI.Reactor` version `{{reactorVersion}}` from NuGet.org by default. +`bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global install (so +it's on PATH cross-shell with no manual `$env:Path` edits), runs +`mur pack-local` to produce local source-built framework snapshots, installs the +Windows App SDK `dotnet new` template pack via `mur templates install`, and +drops the Reactor agent plugin under `~/.claude/plugins/reactor` (symlink when +allowed, copy otherwise). -When it finishes you can immediately run: +To test an unpublished build of the template pack, point bootstrap at a folder +of nupkgs: ```powershell -dotnet new reactorapp -n MyApp -cd MyApp -dotnet run +./bootstrap.ps1 -WinAppSdkTemplatesSource ..\WindowsAppSDK\localpackages ``` ### After `git pull` -The source checkout changes — your local template package, CLI, plugin, and -optional source-built framework snapshots do not, unless you repack them. Two -options: +The source checkout changes — your local framework snapshots, CLI, and plugin +do not, unless you repack them. Two options: ```powershell -mur upgrade # repacks the framework + templates and refreshes plugin +mur upgrade # repacks the framework and refreshes templates + plugin ./bootstrap.ps1 # same, plus updates the `mur` global tool itself ``` @@ -86,23 +121,21 @@ mur doctor ``` Lists every dependency the rest of this guide assumes — .NET 10+ SDK, `mur` on -PATH, current `local-nupkgs/` developer feed, the `reactorapp` template +PATH, current `local-nupkgs/` developer feed, the `dotnet new reactor` template registration, and the optional Claude plugin. Each line is PASS / WARN / FAIL with a one-line remediation for anything broken. > **What this gets you.** A globally-resolvable `mur` (via `~/.dotnet/tools`), -> a locally installed `reactorapp` template that references -> ` Version="{{reactorVersion}}" />`, a local NuGet feed at `/local-nupkgs/` -> for source-built smoke tests, and an agent plugin so AI assistants generate -> against the real factories (`mur --skill` / `mur --api` print the same -> content). Run `mur upgrade` whenever you pull new template, CLI, plugin, or -> framework changes. +> the `dotnet new reactor` templates, a local NuGet feed at +> `/local-nupkgs/` for source-built smoke tests, and an agent plugin so AI +> assistants generate against the real factories (`mur --skill` / `mur --api` +> print the same content). Run `mur upgrade` whenever you pull new CLI, plugin, +> or framework changes. > **Only need the framework package?** Reference the published > `Microsoft.UI.Reactor` package directly from NuGet.org. Run the bootstrap only -> when you want the local project template, the `mur` CLI, or the agent plugin -> from this source checkout. +> when you want the `mur` CLI, source-built framework snapshots, or the agent +> plugin from this source checkout. ### Manual setup @@ -119,8 +152,8 @@ anything goes wrong. | 2 | `git clone` + `cd` | Local source checkout | | 3 | `dotnet pack src/Reactor.Cli` | `Microsoft.UI.Reactor.Cli..nupkg` in `local-nupkgs/` | | 4 | `dotnet tool install -g` | `mur` resolvable cross-shell from `~/.dotnet/tools` | -| 5 | `mur pack-local` | Source-built framework snapshots plus a local `ProjectTemplates` nupkg; generated apps default to the public Reactor preview | -| 6 | `dotnet new uninstall` + `install` | `dotnet new reactorapp` template registered | +| 5 | `mur pack-local` | Source-built framework snapshots in `local-nupkgs/` | +| 6 | `dotnet new install` | `dotnet new reactor` templates registered | | 7 | Symlink/copy `plugins/reactor` | Reactor agent kit under `~/.claude/plugins/reactor` (optional) | | 8 | `mur doctor` | Verification that 1–7 all stuck | @@ -180,20 +213,23 @@ $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" New PowerShell windows pick up the user-PATH change on their own. -**5. Pack local framework snapshots and project templates.** This produces the -source-built `0.0.0-local` framework nupkgs for smoke tests plus the local -`ProjectTemplates` nupkg that installs `dotnet new reactorapp`. The template's -normal default references the public `Microsoft.UI.Reactor` `{{reactorVersion}}` -package. +**5. Pack local framework snapshots.** This produces the source-built +`0.0.0-local` framework nupkgs so recipes and smoke tests in this clone can +consume your working tree instead of the published package. ```powershell mur pack-local # Produces: # local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg # local-nupkgs/Microsoft.UI.Reactor.Advanced.0.0.0-local.nupkg +# local-nupkgs/Microsoft.UI.Reactor.Devtools.0.0.0-local.nupkg # local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg ``` +The last one is the legacy in-repo `dotnet new reactorapp` pack. It is still +built and published, but nothing installs it automatically any more — see the +caveat below. + If you'd rather not depend on the freshly-installed `mur`, you can invoke the source project directly: @@ -202,13 +238,22 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` -c Release "-p:Platform=$hostArch" -- pack-local ``` -**6. Install the `dotnet new reactorapp` template.** The template engine -caches by package id, so a same-version repack can lose to the cached copy. -Always uninstall first. +**6. Install the `dotnet new reactor` templates.** These come from the Windows +App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates 2>$null -dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +``` + +`mur templates install` does the same thing, but resolves the newest published +version first — including prereleases, which a bare `dotnet new install` cannot +reach because it has no `--prerelease` switch and resolves stable-only. Use +`--source ` to install an unpublished build: + +```powershell +mur templates install +mur templates install --source ..\WindowsAppSDK\localpackages +mur templates status ``` **7. (Optional) Install the Reactor agent plugin.** If you use Claude Code @@ -243,9 +288,10 @@ mur doctor #### Refreshing after `git pull` -Without the bootstrap script, repeat **steps 5 and 6** after every pull — -the framework nupkg and the template both need to be regenerated against -the new source. Repeat **steps 3 and 4** only when `src/Reactor.Cli/` +Without the bootstrap script, repeat **step 5** after every pull — the framework +nupkgs need to be regenerated against the new source. Step 6 is a one-time +install: the templates come from NuGet.org, so pulling this repo never +invalidates them. Repeat **steps 3 and 4** only when `src/Reactor.Cli/` itself changes (a running `mur` process cannot replace its own binary, so the install must happen from a shell that isn't already running `mur`). @@ -257,43 +303,50 @@ the install must happen from a shell that isn't already running `mur`). > upgrade verb. -The core framework package is public, but the `reactorapp` project-template -package is still source-installed. If `dotnet new reactorapp` is missing, run -`bootstrap.ps1` (or `mur upgrade` from an already bootstrapped checkout) to -repack and reinstall `Microsoft.UI.Reactor.ProjectTemplates` from -`local-nupkgs/`. The template installer caches by package id, so a same-version -repack can lose to the cached copy — `mur upgrade` handles this by running -`dotnet new uninstall` first. +The Reactor templates ship in the Windows App SDK `dotnet new` pack +(`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` needs +no source checkout. If it's missing, run `dotnet new install +Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — or `mur templates install`, +which additionally resolves prerelease versions that a bare `dotnet new install` +cannot reach. + +This repo also still builds and publishes a legacy +`Microsoft.UI.Reactor.ProjectTemplates` pack providing the older, **unpackaged** +`dotnet new reactorapp` template. `bootstrap.ps1` no longer installs it. Install +it explicitly if you need that shape: +`dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg`. ## Creating a Project -With the template installed, scaffold a new app from anywhere on disk: +With the templates installed, scaffold a new app from anywhere on disk: ```powershell -dotnet new reactorapp -n MyApp +dotnet new reactor -n MyApp cd MyApp dotnet run ``` The template wires up the `Microsoft.UI.Reactor` package reference, the -WinUI 3 target framework, and a working `App.cs` that mounts a single -Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# +WinUI 3 target framework, MSIX packaging, and a working `App.cs` that mounts a +single Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# file. -By default that package reference is -``. -For local framework smoke tests, generate with -`dotnet new reactorapp -n MyLocalApp --MSUIReactorVersion 0.0.0-local` and run +Swap `reactor` for `reactor-mvu`, `reactor-navview`, or `reactor-tabview` to +start from a richer shell. + +By default the package reference tracks the current public preview. For local +framework smoke tests, generate with +`dotnet new reactor -n MyLocalApp --reactor-version 0.0.0-local` and run from inside the source checkout or another folder that has the local feed configured. > **Why a custom template?** A `dotnet new console` does not produce a WinUI > app — it builds a console target with no UI thread, no `OutputType=WinExe`, -> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactorapp` -> sets all of those plus the Reactor package reference and a backdrop-aware -> root component, so you get a window on first `dotnet run` instead of a -> console-host stub. +> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactor` +> sets all of those plus the Reactor package reference, MSIX packaging, and a +> backdrop-aware root component, so you get a window on first `dotnet run` +> instead of a console-host stub. ## Your First App diff --git a/docs/_pipeline/templates/packaging.md.dt b/docs/_pipeline/templates/packaging.md.dt index a2784b9ac..9579e2f27 100644 --- a/docs/_pipeline/templates/packaging.md.dt +++ b/docs/_pipeline/templates/packaging.md.dt @@ -4,13 +4,13 @@ app: packaging order: 2.8 audience: intermediate goal: | - How to ship a Reactor app: the unpackaged WindowsPackageType=None - shape that the project template produces, MSIX for Store / - sideloading, single-file publish, ARM64 as a second runtime - identifier, and what does and doesn't work under Native AOT given - Reactor's reflection-heavy DataGrid / devtools paths. Solid tier — - CSPROJ-driven snippets pulled from real samples + the - `dotnet new reactorapp` template. + How to ship a Reactor app: the packaged single-project MSIX shape + that the project template produces, the unpackaged + WindowsPackageType=None alternative, single-file publish, ARM64 as a + second runtime identifier, and what does and doesn't work under + Native AOT given Reactor's reflection-heavy DataGrid / devtools + paths. Solid tier — CSPROJ-driven snippets pulled from real samples + + the `dotnet new reactor` template. tier: solid --- @@ -19,9 +19,9 @@ tier: solid A Microsoft.UI.Reactor (Reactor) app is a normal WinUI 3 / Windows App SDK executable — `dotnet publish` produces the deployable artifact and the framework itself adds nothing exotic to the project file. What you choose at -publish time is the **shape** of that artifact: an unpackaged folder -(the [`dotnet new reactorapp`](getting-started.md) default), a signed -MSIX, a single-file bundle, or a Native AOT native binary — each +publish time is the **shape** of that artifact: a signed / loose MSIX +(the [`dotnet new reactor`](getting-started.md) default), an unpackaged +folder, a single-file bundle, or a Native AOT native binary — each combined with a `win-x64` or `win-arm64` runtime identifier. The trade-offs are the same ones any WinUI 3 app faces; the Reactor-specific notes on this page cover what changes when your @@ -32,8 +32,8 @@ INPC walker). | Publish shape | Key properties | Runtime identifier | What you get | |---|---|---|---| -| Unpackaged (template default) | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | -| MSIX | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | +| MSIX (template default) | `EnableMsixTooling=true`, `Package.appxmanifest`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix` with package identity. Required for Microsoft Store; the cleanest sideload story for enterprise. `dotnet run` registers a loose layout so F5-equivalent debugging works. | +| Unpackaged | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. No package identity. | | Single-file | `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true` | `win-x64` / `win-arm64` (must be set) | One `.exe` that self-extracts the WinUI runtime to `%TEMP%/.net/` on first launch. | | Native AOT | `PublishAot=true`, `InvariantGlobalization=true` (recommended) | `win-x64` / `win-arm64` (required) | A native binary with no JIT, no `Assembly.GetTypes()`, no `Reflection.Emit`. Fastest cold start; trim-only. | @@ -44,10 +44,46 @@ folder or an MSIX. The decision is usually distribution-channel-first ![Reactor publish pipeline: dotnet publish takes a Reactor CSPROJ to one of three output shapes (unpackaged folder, single-file bundle, signed MSIX), with Native AOT layered on top of either the unpackaged or the MSIX form](images/packaging/publish-pipeline.svg) +## The packaged shape (template default) + +`dotnet new reactor` scaffolds a **single-project MSIX** app: the CSPROJ sets +`EnableMsixTooling=true`, the project carries a `Package.appxmanifest`, and +`Microsoft.Windows.SDK.BuildTools.WinApp` hooks `dotnet run` so it registers a +loose-layout package and launches the app by AUMID. The practical consequence +is that the app has **package identity** from the very first `dotnet run`, so +notifications, background tasks, share targets, and the Windows AI APIs all +work without extra setup. + +Two things this requires that the unpackaged shape does not: + +- **Developer Mode must be enabled** (Settings → System → For developers) for + `dotnet run` to register the loose layout. +- **A concrete architecture.** The template declares + `x86;x64;ARM64`; AnyCPU is rejected. `dotnet build` + on an x64 dev box picks x64, but CI and publish invocations should pass + `-p:Platform=` or `-a ` explicitly. + +To ship it, sign the package with either a Microsoft Store-issued certificate +(for Store submissions) or a self-signed certificate imported into +`Cert:\CurrentUser\My` (for sideloading): + +```xml + + true + ... + +``` + +`Package.appxmanifest` declares the package identity (Publisher, +PackageFamilyName, capabilities, file-type associations). The +[WinUI 3 packaging docs](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/packaging/) +cover the manifest surface in full. + ## The unpackaged shape -`dotnet new reactorapp` scaffolds an unpackaged WinUI 3 project — the -shape every sample in this repo also uses: +Prefer a zip-and-go folder over an MSIX? Drop the packaging pieces and set +`WindowsPackageType=None` — the shape every sample in this repo uses, and the +one the legacy `dotnet new reactorapp` template produced: ```xml snippet="source:samples/TodoApp/TodoApp.csproj#unpackaged-shape" ``` @@ -56,9 +92,9 @@ The load-bearing properties are `UseWinUI=true` (pulls the WinUI 3 XAML runtime), `WindowsPackageType=None` (no MSIX wrapper — `MyApp.exe` runs straight from the publish folder), and the explicit `x64;ARM64` (Windows App SDK self-contained -builds reject the AnyCPU default — the template orders x64 first so -unqualified `dotnet build` picks the right default on x64 dev -machines, with ARM64 second for Snapdragon X). The +builds reject the AnyCPU default — x64 first so unqualified +`dotnet build` picks the right default on x64 dev machines, with +ARM64 second for Snapdragon X). The [`Microsoft.WindowsAppSDK.WinUI`](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.WinUI) sub-package brings the WinUI 3 SDK — reference assemblies plus the MSBuild build/props/targets — while the native WinUI runtime is supplied by the @@ -89,11 +125,11 @@ output `MyApp.xbf`, etc.), and the .NET runtime if `WindowsAppSDKSelfContained=true`. Zip it and you have a sideloadable build that runs on any matching-arch Windows 10 1809+ machine. -## MSIX +## MSIX details For Microsoft Store distribution and most enterprise sideloading, -wrap the same publish output in an MSIX. The single-project MSIX -shape adds three properties on top of the unpackaged CSPROJ: +wrap the same publish output in an MSIX. Starting from the unpackaged +CSPROJ, that means adding three properties: ```xml @@ -167,8 +203,8 @@ and any `System.Drawing.Common` / `TraceEvent` natives transitively pulled in by Reactor) ship per-RID, which is why the runtime identifier matters even for managed-only Reactor code. The repo's sample apps default to `x64;ARM64`; the -`reactorapp` template uses `x64;ARM64;X86` -(X86 retained for parity with the WinUI 3 templates), but Reactor +`reactor` template uses `x86;x64;ARM64` +(x86 retained for parity with the WinUI 3 templates), but Reactor itself is only tested on x64 / ARM64. ## Native AOT @@ -183,14 +219,20 @@ and a runtime identifier: `dotnet publish -c Release -r win-x64` produces a native binary — no `coreclr.dll`, no JIT, ~50 ms cold start versus ~250 ms for the -JIT-based build on the same hardware. The project template gates -the same shape behind a `NativeAot` parameter: +JIT-based build on the same hardware. + +The `dotnet new reactor` template does **not** enable AOT — it ships +`PublishReadyToRun` + `PublishTrimmed` for non-Debug configurations instead. +To go all the way to AOT, add `PublishAot` to the scaffolded CSPROJ yourself: -```xml snippet="source:tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj#template-shape" +```xml + + true + true + ``` -Pass `dotnet new reactorapp --NativeAot true` to get the AOT-enabled -variant. `InvariantGlobalization=true` is paired with `PublishAot` +`InvariantGlobalization=true` is paired with `PublishAot` because the alternative — shipping the full ICU data — pulls in trim warnings that the AOT analyzer flags as actionable. @@ -260,10 +302,11 @@ Trim-friendly deployments don't get any framework-side magic; the same trimmer configuration that works for any WinUI 3 app works here. **`Microsoft.WindowsAppSDK` is explicitly pinned in the template, not -transitively inherited.** The `dotnet new reactorapp` CSPROJ +transitively inherited.** The `dotnet new reactor` CSPROJ references both `Microsoft.UI.Reactor` and `Microsoft.WindowsAppSDK` side-by-side so the SDK version is an obvious knob — bump it in the -scaffolded CSPROJ when you need a specific WinUI patch. The +scaffolded CSPROJ, or pass `--wasdk-version` at scaffold time, when you +need a specific WinUI patch. The repo-internal `WindowsAppSDKVersion` MSBuild property only governs projects under this clone (`Directory.Build.props`); consumer projects pick their version directly. @@ -272,10 +315,13 @@ projects pick their version directly. `Microsoft.UI.Reactor.Devtools` package** (gated by a `Condition="'$(Configuration)' == 'Debug'"` ItemGroup that adds both the package and `RuntimeHostConfigurationOption -Reactor.DevtoolsSupport=true`). F5 from Visual Studio or VS Code -runs the app with `--devtools` (from the scaffolded -`Properties/launchSettings.json`), lighting up the right-click -devtools menu and the docked devtools window. The Reactor Visual +Reactor.DevtoolsSupport=true`). The scaffolded +`Properties/launchSettings.json` ships three profiles: a default +**Package** profile (MSIX launch with package identity), an +**Unpackaged** profile, and an **Unpackaged, Devtools** profile that passes +`--devtools`. Pick the devtools profile in the Visual Studio / VS Code launch +dropdown to light up the right-click devtools menu and the docked devtools +window. The Reactor Visual Studio embedded-preview extension (spec 056) also relies on this Debug wiring — its `dotnet watch run -- --devtools run --embed --embed-host-pid ` activation needs the devtools assembly @@ -290,7 +336,7 @@ in Release too. ## Next Steps - **[Dev Tooling](dev-tooling.md)** — Previous: the inner-loop side of the build pipeline (`mur pack-local`, `dotnet watch`, hot reload). -- **[Getting Started](getting-started.md)** — Where the `dotnet new reactorapp` template that produces the unpackaged shape comes from. +- **[Getting Started](getting-started.md)** — Where the `dotnet new reactor` template that produces the packaged shape comes from. - **[Performance](performance.md)** — When you should reach for AOT (cold-start budgets, startup-perf benchmarks). - **[Perf Instrumentation](perf-instrumentation.md)** — The ETW / EventPipe pipeline that survives AOT publish unchanged. - **[Dev Tooling](dev-tooling.md)** — How the `Reactor.DevtoolsSupport` capability switch combines with `--devtools` activation. diff --git a/docs/contributing/release-runbook.md b/docs/contributing/release-runbook.md index 1c49fdccb..1e316f737 100644 --- a/docs/contributing/release-runbook.md +++ b/docs/contributing/release-runbook.md @@ -143,7 +143,7 @@ dotnet pack tools/Templates/Microsoft.UI.Reactor.Templates.csproj ` -o local-nupkgs ``` -Create a throwaway app from the packed template and verify its `.csproj` references the chosen public version. Skip restore before the tag is published because the new package version will not exist on NuGet.org yet: +Create a throwaway app from the packed **legacy** template (`Microsoft.UI.Reactor.ProjectTemplates`, `dotnet new reactorapp`) and verify its `.csproj` references the chosen public version. This package is still published even though `bootstrap.ps1` no longer installs it — the supported scaffolding path is now `dotnet new reactor` from the Windows App SDK template pack, which is versioned and released by the WindowsAppSDK repo, not here. Skip restore before the tag is published because the new package version will not exist on NuGet.org yet: ```powershell dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates diff --git a/docs/guide/dev-tooling.md b/docs/guide/dev-tooling.md index 0980cfa67..df91d88b1 100644 --- a/docs/guide/dev-tooling.md +++ b/docs/guide/dev-tooling.md @@ -177,7 +177,8 @@ subcommands map one-to-one to the workflows below. | `mur loc` | Run the localization pipeline (extract strings, validate `.resw`, generate manifests) | `mur loc extract` | | `mur devtools` | Start the MCP server for VS Code or agent integration | `mur devtools serve` | | `mur check` | Repo-health checks (cref validity, namespace policy, "did you mean" suggestions) | `mur check` | -| `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; the app template defaults to the public Reactor preview unless `--MSUIReactorVersion` is supplied | `mur pack-local` | +| `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; scaffolded apps default to the public Reactor preview unless `--reactor-version` is supplied | `mur pack-local` | +| `mur templates install` / `mur templates status` | Install or check the Windows App SDK `dotnet new` pack that provides `dotnet new reactor` (resolves prereleases that a bare `dotnet new install` can't reach) | `mur templates install` | `mur docs compile` is the workflow you reach for most often. See [the doc-pipeline contributor guide](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/contributing/doc-pipeline.md) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 5e13afdf4..0710553ba 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -19,17 +19,55 @@ the rest of the docset elaborates. > **Public preview package available.** Reactor ships `Microsoft.UI.Reactor` -> `0.1.0-preview.13` on NuGet.org. The project template package is still -> installed from source for now; `bootstrap.ps1` installs `mur`, packs/registers -> the local `reactorapp` template, and stamps generated apps to reference the -> public preview package by default. Broader signed distribution is tracked in +> `0.1.0-preview.13` on NuGet.org, and the project templates ship in the +> official Windows App SDK `dotnet new` pack +> (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`) — so `dotnet new reactor` +> works from a plain .NET SDK install, no source checkout required. +> `bootstrap.ps1` is for *contributors*: it installs `mur`, packs source-built +> framework snapshots, and registers those same templates. Broader signed +> distribution is tracked in > [spec 022](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/specs/022-packaging-and-distribution.md). Reactor is a declarative UI framework for building native Windows apps in pure C#. No XAML, no data binding, no view models. You describe your UI as a function of state and Reactor keeps the screen in sync. -## Setup (one-time) +## Setup + +If you just want to build an app, install the template pack and go — you do not +need to clone this repo: + +```powershell +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new reactor -n MyApp +cd MyApp +dotnet run +``` + +That gives you four starting points: + +| Template | What it scaffolds | +|---|---| +| `reactor` | Blank Reactor app. Start here if unsure. | +| `reactor-mvu` | Model-View-Update counter built on `UseReducer` | +| `reactor-navview` | `NavigationView` shell with multiple pages | +| `reactor-tabview` | `TabView` shell whose tabs live in the title bar | + +Scaffolded apps are **packaged** (single-project MSIX), so `dotnet run` +registers a loose-layout package and launches the app with full package +identity — the same thing F5 does in Visual Studio. That means features +requiring identity (notifications, background tasks, Windows AI APIs) work out +of the box. See [Packaging](packaging.md) to switch to an unpackaged shape. + +> **Developer Mode must be on** for `dotnet run` to register the loose-layout +> package: Settings → System → For developers → Developer Mode → On. + +Pin package versions at scaffold time with `--reactor-version` (the +`Microsoft.UI.Reactor` package) or `--wasdk-version` (the Windows App SDK). + +## Contributor setup (one-time) + +Working *on* Reactor rather than with it? Clone and bootstrap: ```powershell git clone https://github.com/microsoft/microsoft-ui-reactor.git @@ -37,30 +75,27 @@ cd microsoft-ui-reactor ./bootstrap.ps1 ``` -That's it. `bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global -install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs -`mur pack-local` to produce local source-built framework snapshots and the -matching `ProjectTemplates` nupkg, registers the `dotnet new reactorapp` -template, and drops the Reactor agent plugin under `~/.claude/plugins/reactor` -(symlink when allowed, copy otherwise). Apps created from that template reference -`Microsoft.UI.Reactor` version `0.1.0-preview.13` from NuGet.org by default. +`bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global install (so +it's on PATH cross-shell with no manual `$env:Path` edits), runs +`mur pack-local` to produce local source-built framework snapshots, installs the +Windows App SDK `dotnet new` template pack via `mur templates install`, and +drops the Reactor agent plugin under `~/.claude/plugins/reactor` (symlink when +allowed, copy otherwise). -When it finishes you can immediately run: +To test an unpublished build of the template pack, point bootstrap at a folder +of nupkgs: ```powershell -dotnet new reactorapp -n MyApp -cd MyApp -dotnet run +./bootstrap.ps1 -WinAppSdkTemplatesSource ..\WindowsAppSDK\localpackages ``` ### After `git pull` -The source checkout changes — your local template package, CLI, plugin, and -optional source-built framework snapshots do not, unless you repack them. Two -options: +The source checkout changes — your local framework snapshots, CLI, and plugin +do not, unless you repack them. Two options: ```powershell -mur upgrade # repacks the framework + templates and refreshes plugin +mur upgrade # repacks the framework and refreshes templates + plugin ./bootstrap.ps1 # same, plus updates the `mur` global tool itself ``` @@ -74,23 +109,21 @@ mur doctor ``` Lists every dependency the rest of this guide assumes — .NET 10+ SDK, `mur` on -PATH, current `local-nupkgs/` developer feed, the `reactorapp` template +PATH, current `local-nupkgs/` developer feed, the `dotnet new reactor` template registration, and the optional Claude plugin. Each line is PASS / WARN / FAIL with a one-line remediation for anything broken. > **What this gets you.** A globally-resolvable `mur` (via `~/.dotnet/tools`), -> a locally installed `reactorapp` template that references -> ` Version="0.1.0-preview.13" />`, a local NuGet feed at `/local-nupkgs/` -> for source-built smoke tests, and an agent plugin so AI assistants generate -> against the real factories (`mur --skill` / `mur --api` print the same -> content). Run `mur upgrade` whenever you pull new template, CLI, plugin, or -> framework changes. +> the `dotnet new reactor` templates, a local NuGet feed at +> `/local-nupkgs/` for source-built smoke tests, and an agent plugin so AI +> assistants generate against the real factories (`mur --skill` / `mur --api` +> print the same content). Run `mur upgrade` whenever you pull new CLI, plugin, +> or framework changes. > **Only need the framework package?** Reference the published > `Microsoft.UI.Reactor` package directly from NuGet.org. Run the bootstrap only -> when you want the local project template, the `mur` CLI, or the agent plugin -> from this source checkout. +> when you want the `mur` CLI, source-built framework snapshots, or the agent +> plugin from this source checkout. ### Manual setup @@ -107,8 +140,8 @@ anything goes wrong. | 2 | `git clone` + `cd` | Local source checkout | | 3 | `dotnet pack src/Reactor.Cli` | `Microsoft.UI.Reactor.Cli..nupkg` in `local-nupkgs/` | | 4 | `dotnet tool install -g` | `mur` resolvable cross-shell from `~/.dotnet/tools` | -| 5 | `mur pack-local` | Source-built framework snapshots plus a local `ProjectTemplates` nupkg; generated apps default to the public Reactor preview | -| 6 | `dotnet new uninstall` + `install` | `dotnet new reactorapp` template registered | +| 5 | `mur pack-local` | Source-built framework snapshots in `local-nupkgs/` | +| 6 | `dotnet new install` | `dotnet new reactor` templates registered | | 7 | Symlink/copy `plugins/reactor` | Reactor agent kit under `~/.claude/plugins/reactor` (optional) | | 8 | `mur doctor` | Verification that 1–7 all stuck | @@ -168,20 +201,23 @@ $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" New PowerShell windows pick up the user-PATH change on their own. -**5. Pack local framework snapshots and project templates.** This produces the -source-built `0.0.0-local` framework nupkgs for smoke tests plus the local -`ProjectTemplates` nupkg that installs `dotnet new reactorapp`. The template's -normal default references the public `Microsoft.UI.Reactor` `0.1.0-preview.13` -package. +**5. Pack local framework snapshots.** This produces the source-built +`0.0.0-local` framework nupkgs so recipes and smoke tests in this clone can +consume your working tree instead of the published package. ```powershell mur pack-local # Produces: # local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg # local-nupkgs/Microsoft.UI.Reactor.Advanced.0.0.0-local.nupkg +# local-nupkgs/Microsoft.UI.Reactor.Devtools.0.0.0-local.nupkg # local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg ``` +The last one is the legacy in-repo `dotnet new reactorapp` pack. It is still +built and published, but nothing installs it automatically any more — see the +caveat below. + If you'd rather not depend on the freshly-installed `mur`, you can invoke the source project directly: @@ -190,13 +226,22 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` -c Release "-p:Platform=$hostArch" -- pack-local ``` -**6. Install the `dotnet new reactorapp` template.** The template engine -caches by package id, so a same-version repack can lose to the cached copy. -Always uninstall first. +**6. Install the `dotnet new reactor` templates.** These come from the Windows +App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates 2>$null -dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +``` + +`mur templates install` does the same thing, but resolves the newest published +version first — including prereleases, which a bare `dotnet new install` cannot +reach because it has no `--prerelease` switch and resolves stable-only. Use +`--source ` to install an unpublished build: + +```powershell +mur templates install +mur templates install --source ..\WindowsAppSDK\localpackages +mur templates status ``` **7. (Optional) Install the Reactor agent plugin.** If you use Claude Code @@ -231,9 +276,10 @@ mur doctor #### Refreshing after `git pull` -Without the bootstrap script, repeat **steps 5 and 6** after every pull — -the framework nupkg and the template both need to be regenerated against -the new source. Repeat **steps 3 and 4** only when `src/Reactor.Cli/` +Without the bootstrap script, repeat **step 5** after every pull — the framework +nupkgs need to be regenerated against the new source. Step 6 is a one-time +install: the templates come from NuGet.org, so pulling this repo never +invalidates them. Repeat **steps 3 and 4** only when `src/Reactor.Cli/` itself changes (a running `mur` process cannot replace its own binary, so the install must happen from a shell that isn't already running `mur`). @@ -244,42 +290,48 @@ the install must happen from a shell that isn't already running `mur`). > with no arch-aware PATH munging, and `dotnet tool update -g` becomes the > upgrade verb. -> **Caveat:** The core framework package is public, but the `reactorapp` project-template -> package is still source-installed. If `dotnet new reactorapp` is missing, run -> `bootstrap.ps1` (or `mur upgrade` from an already bootstrapped checkout) to -> repack and reinstall `Microsoft.UI.Reactor.ProjectTemplates` from -> `local-nupkgs/`. The template installer caches by package id, so a same-version -> repack can lose to the cached copy — `mur upgrade` handles this by running -> `dotnet new uninstall` first. +> **Caveat:** The Reactor templates ship in the Windows App SDK `dotnet new` pack +> (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` needs +> no source checkout. If it's missing, run `dotnet new install +> Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — or `mur templates install`, +> which additionally resolves prerelease versions that a bare `dotnet new install` +> cannot reach. +> This repo also still builds and publishes a legacy +> `Microsoft.UI.Reactor.ProjectTemplates` pack providing the older, **unpackaged** +> `dotnet new reactorapp` template. `bootstrap.ps1` no longer installs it. Install +> it explicitly if you need that shape: +> `dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg`. ## Creating a Project -With the template installed, scaffold a new app from anywhere on disk: +With the templates installed, scaffold a new app from anywhere on disk: ```powershell -dotnet new reactorapp -n MyApp +dotnet new reactor -n MyApp cd MyApp dotnet run ``` The template wires up the `Microsoft.UI.Reactor` package reference, the -WinUI 3 target framework, and a working `App.cs` that mounts a single -Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# +WinUI 3 target framework, MSIX packaging, and a working `App.cs` that mounts a +single Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# file. -By default that package reference is -``. -For local framework smoke tests, generate with -`dotnet new reactorapp -n MyLocalApp --MSUIReactorVersion 0.0.0-local` and run +Swap `reactor` for `reactor-mvu`, `reactor-navview`, or `reactor-tabview` to +start from a richer shell. + +By default the package reference tracks the current public preview. For local +framework smoke tests, generate with +`dotnet new reactor -n MyLocalApp --reactor-version 0.0.0-local` and run from inside the source checkout or another folder that has the local feed configured. > **Why a custom template?** A `dotnet new console` does not produce a WinUI > app — it builds a console target with no UI thread, no `OutputType=WinExe`, -> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactorapp` -> sets all of those plus the Reactor package reference and a backdrop-aware -> root component, so you get a window on first `dotnet run` instead of a -> console-host stub. +> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactor` +> sets all of those plus the Reactor package reference, MSIX packaging, and a +> backdrop-aware root component, so you get a window on first `dotnet run` +> instead of a console-host stub. ## Your First App diff --git a/docs/guide/packaging.md b/docs/guide/packaging.md index 65fce1502..7c9708299 100644 --- a/docs/guide/packaging.md +++ b/docs/guide/packaging.md @@ -4,9 +4,9 @@ A Microsoft.UI.Reactor (Reactor) app is a normal WinUI 3 / Windows App SDK executable — `dotnet publish` produces the deployable artifact and the framework itself adds nothing exotic to the project file. What you choose at -publish time is the **shape** of that artifact: an unpackaged folder -(the [`dotnet new reactorapp`](getting-started.md) default), a signed -MSIX, a single-file bundle, or a Native AOT native binary — each +publish time is the **shape** of that artifact: a signed / loose MSIX +(the [`dotnet new reactor`](getting-started.md) default), an unpackaged +folder, a single-file bundle, or a Native AOT native binary — each combined with a `win-x64` or `win-arm64` runtime identifier. The trade-offs are the same ones any WinUI 3 app faces; the Reactor-specific notes on this page cover what changes when your @@ -17,8 +17,8 @@ INPC walker). | Publish shape | Key properties | Runtime identifier | What you get | |---|---|---|---| -| Unpackaged (template default) | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | -| MSIX | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | +| MSIX (template default) | `EnableMsixTooling=true`, `Package.appxmanifest`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix` with package identity. Required for Microsoft Store; the cleanest sideload story for enterprise. `dotnet run` registers a loose layout so F5-equivalent debugging works. | +| Unpackaged | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. No package identity. | | Single-file | `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true` | `win-x64` / `win-arm64` (must be set) | One `.exe` that self-extracts the WinUI runtime to `%TEMP%/.net/` on first launch. | | Native AOT | `PublishAot=true`, `InvariantGlobalization=true` (recommended) | `win-x64` / `win-arm64` (required) | A native binary with no JIT, no `Assembly.GetTypes()`, no `Reflection.Emit`. Fastest cold start; trim-only. | @@ -29,10 +29,46 @@ folder or an MSIX. The decision is usually distribution-channel-first ![Reactor publish pipeline: dotnet publish takes a Reactor CSPROJ to one of three output shapes (unpackaged folder, single-file bundle, signed MSIX), with Native AOT layered on top of either the unpackaged or the MSIX form](images/packaging/publish-pipeline.svg) +## The packaged shape (template default) + +`dotnet new reactor` scaffolds a **single-project MSIX** app: the CSPROJ sets +`EnableMsixTooling=true`, the project carries a `Package.appxmanifest`, and +`Microsoft.Windows.SDK.BuildTools.WinApp` hooks `dotnet run` so it registers a +loose-layout package and launches the app by AUMID. The practical consequence +is that the app has **package identity** from the very first `dotnet run`, so +notifications, background tasks, share targets, and the Windows AI APIs all +work without extra setup. + +Two things this requires that the unpackaged shape does not: + +- **Developer Mode must be enabled** (Settings → System → For developers) for + `dotnet run` to register the loose layout. +- **A concrete architecture.** The template declares + `x86;x64;ARM64`; AnyCPU is rejected. `dotnet build` + on an x64 dev box picks x64, but CI and publish invocations should pass + `-p:Platform=` or `-a ` explicitly. + +To ship it, sign the package with either a Microsoft Store-issued certificate +(for Store submissions) or a self-signed certificate imported into +`Cert:\CurrentUser\My` (for sideloading): + +```xml + + true + ... + +``` + +`Package.appxmanifest` declares the package identity (Publisher, +PackageFamilyName, capabilities, file-type associations). The +[WinUI 3 packaging docs](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/packaging/) +cover the manifest surface in full. + ## The unpackaged shape -`dotnet new reactorapp` scaffolds an unpackaged WinUI 3 project — the -shape every sample in this repo also uses: +Prefer a zip-and-go folder over an MSIX? Drop the packaging pieces and set +`WindowsPackageType=None` — the shape every sample in this repo uses, and the +one the legacy `dotnet new reactorapp` template produced: ```xml snippet="source:samples/TodoApp/TodoApp.csproj#unpackaged-shape" ``` @@ -41,9 +77,9 @@ The load-bearing properties are `UseWinUI=true` (pulls the WinUI 3 XAML runtime), `WindowsPackageType=None` (no MSIX wrapper — `MyApp.exe` runs straight from the publish folder), and the explicit `x64;ARM64` (Windows App SDK self-contained -builds reject the AnyCPU default — the template orders x64 first so -unqualified `dotnet build` picks the right default on x64 dev -machines, with ARM64 second for Snapdragon X). The +builds reject the AnyCPU default — x64 first so unqualified +`dotnet build` picks the right default on x64 dev machines, with +ARM64 second for Snapdragon X). The [`Microsoft.WindowsAppSDK.WinUI`](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.WinUI) sub-package brings the WinUI 3 SDK — reference assemblies plus the MSBuild build/props/targets — while the native WinUI runtime is supplied by the @@ -74,11 +110,11 @@ output `MyApp.xbf`, etc.), and the .NET runtime if `WindowsAppSDKSelfContained=true`. Zip it and you have a sideloadable build that runs on any matching-arch Windows 10 1809+ machine. -## MSIX +## MSIX details For Microsoft Store distribution and most enterprise sideloading, -wrap the same publish output in an MSIX. The single-project MSIX -shape adds three properties on top of the unpackaged CSPROJ: +wrap the same publish output in an MSIX. Starting from the unpackaged +CSPROJ, that means adding three properties: ```xml @@ -152,8 +188,8 @@ and any `System.Drawing.Common` / `TraceEvent` natives transitively pulled in by Reactor) ship per-RID, which is why the runtime identifier matters even for managed-only Reactor code. The repo's sample apps default to `x64;ARM64`; the -`reactorapp` template uses `x64;ARM64;X86` -(X86 retained for parity with the WinUI 3 templates), but Reactor +`reactor` template uses `x86;x64;ARM64` +(x86 retained for parity with the WinUI 3 templates), but Reactor itself is only tested on x64 / ARM64. ## Native AOT @@ -168,14 +204,20 @@ and a runtime identifier: `dotnet publish -c Release -r win-x64` produces a native binary — no `coreclr.dll`, no JIT, ~50 ms cold start versus ~250 ms for the -JIT-based build on the same hardware. The project template gates -the same shape behind a `NativeAot` parameter: +JIT-based build on the same hardware. + +The `dotnet new reactor` template does **not** enable AOT — it ships +`PublishReadyToRun` + `PublishTrimmed` for non-Debug configurations instead. +To go all the way to AOT, add `PublishAot` to the scaffolded CSPROJ yourself: -```xml snippet="source:tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj#template-shape" +```xml + + true + true + ``` -Pass `dotnet new reactorapp --NativeAot true` to get the AOT-enabled -variant. `InvariantGlobalization=true` is paired with `PublishAot` +`InvariantGlobalization=true` is paired with `PublishAot` because the alternative — shipping the full ICU data — pulls in trim warnings that the AOT analyzer flags as actionable. @@ -243,10 +285,11 @@ Trim-friendly deployments don't get any framework-side magic; the same trimmer configuration that works for any WinUI 3 app works here. **`Microsoft.WindowsAppSDK` is explicitly pinned in the template, not -transitively inherited.** The `dotnet new reactorapp` CSPROJ +transitively inherited.** The `dotnet new reactor` CSPROJ references both `Microsoft.UI.Reactor` and `Microsoft.WindowsAppSDK` side-by-side so the SDK version is an obvious knob — bump it in the -scaffolded CSPROJ when you need a specific WinUI patch. The +scaffolded CSPROJ, or pass `--wasdk-version` at scaffold time, when you +need a specific WinUI patch. The repo-internal `WindowsAppSDKVersion` MSBuild property only governs projects under this clone (`Directory.Build.props`); consumer projects pick their version directly. @@ -255,10 +298,13 @@ projects pick their version directly. `Microsoft.UI.Reactor.Devtools` package** (gated by a `Condition="'$(Configuration)' == 'Debug'"` ItemGroup that adds both the package and `RuntimeHostConfigurationOption -Reactor.DevtoolsSupport=true`). F5 from Visual Studio or VS Code -runs the app with `--devtools` (from the scaffolded -`Properties/launchSettings.json`), lighting up the right-click -devtools menu and the docked devtools window. The Reactor Visual +Reactor.DevtoolsSupport=true`). The scaffolded +`Properties/launchSettings.json` ships three profiles: a default +**Package** profile (MSIX launch with package identity), an +**Unpackaged** profile, and an **Unpackaged, Devtools** profile that passes +`--devtools`. Pick the devtools profile in the Visual Studio / VS Code launch +dropdown to light up the right-click devtools menu and the docked devtools +window. The Reactor Visual Studio embedded-preview extension (spec 056) also relies on this Debug wiring — its `dotnet watch run -- --devtools run --embed --embed-host-pid ` activation needs the devtools assembly @@ -273,7 +319,7 @@ in Release too. ## Next Steps - **[Dev Tooling](dev-tooling.md)** — Previous: the inner-loop side of the build pipeline (`mur pack-local`, `dotnet watch`, hot reload). -- **[Getting Started](getting-started.md)** — Where the `dotnet new reactorapp` template that produces the unpackaged shape comes from. +- **[Getting Started](getting-started.md)** — Where the `dotnet new reactor` template that produces the packaged shape comes from. - **[Performance](performance.md)** — When you should reach for AOT (cold-start budgets, startup-perf benchmarks). - **[Perf Instrumentation](perf-instrumentation.md)** — The ETW / EventPipe pipeline that survives AOT publish unchanged. - **[Dev Tooling](dev-tooling.md)** — How the `Reactor.DevtoolsSupport` capability switch combines with `--devtools` activation. diff --git a/plugins/reactor/agents/reactor-dev.agent.md b/plugins/reactor/agents/reactor-dev.agent.md index d3f4c8bf0..15a661e22 100644 --- a/plugins/reactor/agents/reactor-dev.agent.md +++ b/plugins/reactor/agents/reactor-dev.agent.md @@ -6,20 +6,22 @@ user-invocable: true ## Process -> ### ⚠️ ALWAYS start a new app with `dotnet new reactorapp` +> ### ⚠️ ALWAYS start a new app with `dotnet new reactor` > > Unless the user has explicitly asked you to do something else (e.g. modify an existing project, write a single-file `#:package` script on purpose, or convert from XAML/MVVM into an existing tree), **your very first action on a new Reactor app is:** > > ``` -> dotnet new reactorapp -n +> dotnet new reactor -n > ``` > > Run it from the workspace root. It produces a working `.csproj` and `App.cs` already wired against `Microsoft.UI.Reactor` (the `App.cs` carries its own `using` directives — there is no `GlobalUsings.cs`, and you should not add one). **Edit that. Do not hand-write a `.csproj` and `App.cs` from scratch** — that path consistently leads to invented API names (`UseElementFocus`, `AutomationLandmarkType.Complementary`, etc.), wasted `mur check` round-trips, and longer sessions. > -> If the template isn't installed yet, install it before scaffolding: +> Richer starting points: `reactor-mvu` (Model-View-Update via `UseReducer`), `reactor-navview` (`NavigationView` shell), `reactor-tabview` (`TabView` shell). +> +> If the templates aren't installed yet, install the pack before scaffolding: > > ``` -> dotnet new install Microsoft.UI.Reactor.ProjectTemplates +> dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates > ``` You build Reactor apps in this rhythm: scaffold → understand requirements → draft component tree → write files in a batch → `mur check`. @@ -31,7 +33,7 @@ Before continuing Then for each task: -1. **Scaffold first** (see the callout above). For a new app: `dotnet new reactorapp -n `. Skip this step *only* if the user has told you to write a single-file script, edit an existing project, or otherwise asked for a non-scaffolded shape. +1. **Scaffold first** (see the callout above). For a new app: `dotnet new reactor -n `. Skip this step *only* if the user has told you to write a single-file script, edit an existing project, or otherwise asked for a non-scaffolded shape. 2. **Understand the task.** Note what the app needs to do. Don't guess at requirements. 3. **Draft.** Sketch the component tree, identify state, decide where each piece lives. If you know how you'd build the equivalent in React, you already know the shape — just translate to the C# spelling. 4. **Write the files in a batch.** Add models and child components in one stretch on top of the scaffolded `App.cs`. Don't stop and rebuild after each file — build once at the end. diff --git a/plugins/reactor/skills/reactor-getting-started/SKILL.md b/plugins/reactor/skills/reactor-getting-started/SKILL.md index eb89e02d4..dc38e8a67 100644 --- a/plugins/reactor/skills/reactor-getting-started/SKILL.md +++ b/plugins/reactor/skills/reactor-getting-started/SKILL.md @@ -50,19 +50,21 @@ description: "Reactor essentials in one place — React-to-Reactor mental model, ## Starting a new app -`dotnet new reactorapp -n ` scaffolds the canonical shape: `App.cs` (entry point + initial component, with the seven-line using block at the top) plus `.csproj`. See the anti-probe + `mur check` notes under "Use a `.csproj` …" below for what comes out of the scaffold. +`dotnet new reactor -n ` scaffolds the canonical shape: `App.cs` (entry point + initial component, with the seven-line using block at the top) plus `.csproj`. Richer shells: `reactor-mvu`, `reactor-navview`, `reactor-tabview`. See the anti-probe + `mur check` notes under "Use a `.csproj` …" below for what comes out of the scaffold. For a single-file `dotnet run App.cs` demo (no `.csproj`), prepend the file-level `#:package` / `#:property` headers — see `reactor-build-and-check`'s single-file-scripts section. ## Use a `.csproj` when you need … -… multiple files, **analyzers** (single-file `.cs` builds don't load them), or shared project references. `dotnet new reactorapp` scaffolds the canonical csproj — you don't need to author one from scratch. +… multiple files, **analyzers** (single-file `.cs` builds don't load them), or shared project references. `dotnet new reactor` scaffolds the canonical csproj — you don't need to author one from scratch. -`WindowsPackageType` MUST be `None` (unpackaged, no App.xaml). `UseWinUI` MUST be `true`. **No XAML files of any kind.** +For a **hand-authored** csproj, `WindowsPackageType` MUST be `None` (unpackaged, no App.xaml) and `UseWinUI` MUST be `true`. Apps from `dotnet new reactor` are **packaged** (single-project MSIX) instead and omit `WindowsPackageType` — leave their packaging properties alone. Either way: **no XAML files of any kind.** -**After `dotnet new reactorapp -n `, the workspace contains exactly two source files: `App.cs` (entry point + initial component) and `.csproj`, plus a `Properties/launchSettings.json` for F5.** There is no `Program.cs` and no `GlobalUsings.cs` — modify `App.cs` in place. The `.csproj` does **not** enable implicit usings; `App.cs` has its own `using` directives at the top — the same set listed in the *Required imports* section below — which is the only place you add new namespaces (e.g. `using System.Linq;` when you reach for `.Select(...)`). Don't probe the `.csproj` after scaffolding unless you're adding a `PackageReference` or changing a property — `Restore succeeded.` in the scaffold stdout is the only confirmation you need. +**After `dotnet new reactor -n `, the entry point is `App.cs`** (entry point + initial component) next to `.csproj`. The template also emits packaging scaffolding you normally don't touch: `Package.appxmanifest`, `app.manifest`, `Assets/`, and `Properties/launchSettings.json` + `Properties/PublishProfiles/`. There is no `Program.cs` and no `GlobalUsings.cs` — modify `App.cs` in place. `App.cs` has its own `using` directives at the top — the same set listed in the *Required imports* section below — which is the only place you add new namespaces (e.g. `using System.Linq;` when you reach for `.Select(...)`). Don't probe the `.csproj` after scaffolding unless you're adding a `PackageReference` or changing a property — `Restore succeeded.` in the scaffold stdout is the only confirmation you need. -**The scaffolded csproj ships with `WindowsAppSDKSelfContained=true` and a Debug-only ItemGroup that adds `Microsoft.UI.Reactor.Devtools` + `Reactor.DevtoolsSupport=true`.** Together they make `dotnet watch run` (and the very rough, experimental Visual Studio embedded-preview extension) hot-reload safe and F5 (which passes `--devtools` from `Properties/launchSettings.json`) bring up the devtools menu. The VS extension is currently the roughest Reactor surface; do not present it as stable. Release builds drop the devtools package and host-config switch so trim / AOT analyzers stay quiet — see the `packaging` guide for the full rationale before flipping either knob. +**Scaffolded apps are packaged, so `dotnet run` launches them with MSIX package identity** — the F5 equivalent. That needs **Developer Mode on** (Settings → System → For developers) to register the loose-layout package, and a concrete architecture (the template declares `x86;x64;ARM64`; AnyCPU is rejected). + +**The scaffolded csproj ships a Debug-only ItemGroup that adds `Microsoft.UI.Reactor.Devtools` + `Reactor.DevtoolsSupport=true`.** `Properties/launchSettings.json` carries three profiles: a default **Package** profile, an **Unpackaged** profile, and an **Unpackaged, Devtools** profile that passes `--devtools` to bring up the devtools menu. Pick the devtools profile in the launch dropdown when you want it. Release builds drop the devtools package and host-config switch so trim / AOT analyzers stay quiet — see the `packaging` guide for the full rationale before flipping either knob. **Verify your edits with `mur check`** before declaring done. From the project directory: `mur check` (no arguments) runs `dotnet build` and emits one compressed line per diagnostic with a `→ try:` suggestion when the engine recognizes the mistake; `mur check --final` is the explicit "I am done iterating" sweep that emits the full diagnostic set including suppressed iteration-mode warnings. For anything more involved than the build/fix loop — strict-mode failures, custom diagnostic gating, MSBuild passthrough flags — load the `reactor-build-and-check` skill. @@ -448,7 +450,7 @@ Nested `.Provide()` overrides the outer for its subtree only. If no provider is > ⚠️ **Platform flag required when working *inside this repo* (selfhost)**: always build samples / in-repo projects with an explicit platform: `dotnet build -p:Platform=x64` (or `ARM64`). Omitting `-p:Platform=...` causes `WindowsAppSDKSelfContained` errors. This applies to `dotnet build`, `dotnet run`, and `mur check` invocations alike. > -> The `dotnet new reactorapp` template auto-resolves `RuntimeIdentifier` from the host SDK when `Platform`/`RuntimeIdentifier` aren't explicit, so consumer projects scaffolded outside the repo build with bare `dotnet build` / F5 — the rule above only applies in the selfhost tree. +> The `dotnet new reactor` template auto-resolves `RuntimeIdentifier` from the host SDK when `Platform`/`RuntimeIdentifier` aren't explicit, so consumer projects scaffolded outside the repo build with bare `dotnet build` / F5 — the rule above only applies in the selfhost tree. ## Where the skill content comes from (and the api index) diff --git a/src/Reactor.Cli/Doctor/DoctorCommand.cs b/src/Reactor.Cli/Doctor/DoctorCommand.cs index f8a0ea295..246a8a5b9 100644 --- a/src/Reactor.Cli/Doctor/DoctorCommand.cs +++ b/src/Reactor.Cli/Doctor/DoctorCommand.cs @@ -4,7 +4,7 @@ // depends on. Prints a one-line PASS / WARN / FAIL per check and exits non-zero // only when there are FAILs — WARNs (e.g. missing-template-enumeration or a // stale-looking checkout) still exit 0 since the install is usable. Designed -// to be the first thing a confused user runs after `dotnet new reactorapp` +// to be the first thing a confused user runs after `dotnet new reactor` // fails — every FAIL prints a copy-pasteable next step. // // Checks (in order): @@ -17,13 +17,17 @@ // 5. local-nupkgs/Microsoft.UI.Reactor.Advanced..nupkg present // (warn-only — opt-in Win2D canvas package) // 6. local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates..nupkg present -// 7. `dotnet new` template list includes `reactorapp` (always runs — does -// not depend on the repo checkout being found) +// (warn-only — the legacy `dotnet new reactorapp` pack is still built and +// published, but nothing installs it automatically any more) +// 7. The Windows App SDK `dotnet new` template pack is registered, which is +// what provides `dotnet new reactor` (always runs — does not depend on +// the repo checkout being found) // 8. Claude plugin at ~/.claude/plugins/reactor (informational only; not // every developer uses Claude Code) using System.Diagnostics; using Microsoft.UI.Reactor.Cli.Pack; +using Microsoft.UI.Reactor.Cli.Templates; namespace Microsoft.UI.Reactor.Cli.Doctor; @@ -125,10 +129,14 @@ public static int Run(string[] args) Pass("local Advanced nupkg", $"{Path.GetFileName(advancedNupkg)} ({FormatAge(File.GetLastWriteTimeUtc(advancedNupkg))})"); } + // The legacy `dotnet new reactorapp` pack. `mur pack-local` still + // produces it and the release workflow still publishes it, but + // bootstrap no longer installs it — so a missing nupkg here means an + // incomplete pack-local, not a broken scaffolding story. Warn, don't fail. if (!File.Exists(templateNupkg)) { - Fail("local template nupkg", $"missing {templateNupkg}. Run `mur pack-local`."); - failures++; + Warn("local template nupkg", $"missing {templateNupkg}. Run `mur pack-local` if you need the legacy `dotnet new reactorapp` package."); + warnings++; } else { @@ -136,20 +144,20 @@ public static int Run(string[] args) } } - // 4. dotnet new reactorapp template - var templates = ListInstalledTemplates(); - if (templates is null) + // 4. `dotnet new reactor` templates (Windows App SDK template pack). + var templatePackInstalled = WinAppSdkTemplates.IsInstalled(); + if (templatePackInstalled is null) { - Warn("dotnet new template", "could not enumerate `dotnet new` templates"); + Warn("dotnet new template", "could not enumerate installed `dotnet new` template packages"); warnings++; } - else if (templates.Any(t => t.IndexOf("reactorapp", StringComparison.OrdinalIgnoreCase) >= 0)) + else if (templatePackInstalled.Value) { - Pass("dotnet new template", "reactorapp registered"); + Pass("dotnet new template", $"{WinAppSdkTemplates.PackageId} registered (`dotnet new {WinAppSdkTemplates.BlankShortName}`)"); } else { - Fail("dotnet new template", "reactorapp not registered. Run `mur upgrade` or `dotnet new install /local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg`."); + Fail("dotnet new template", $"{WinAppSdkTemplates.PackageId} not registered, so `dotnet new {WinAppSdkTemplates.BlankShortName}` is unavailable. Run `./bootstrap.ps1`, `mur upgrade`, or `dotnet new install {WinAppSdkTemplates.PackageId}`."); failures++; } @@ -181,7 +189,7 @@ public static int Run(string[] args) Console.WriteLine($" OK — {warnings} warning(s). Your install is functional."); return 0; } - Console.WriteLine(" All checks passed. You're ready to `dotnet new reactorapp -n MyApp`."); + Console.WriteLine($" All checks passed. You're ready to `dotnet new {WinAppSdkTemplates.BlankShortName} -n MyApp`."); return 0; } @@ -315,31 +323,6 @@ static bool IsGlobalToolInstalled() } } - static List? ListInstalledTemplates() - { - try - { - var psi = new ProcessStartInfo("dotnet") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - ArgumentList = { "new", "list" }, - }; - using var proc = Process.Start(psi); - if (proc is null) return null; - var output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(); - return output.Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.TrimEnd()) - .ToList(); - } - catch - { - return null; - } - } - static string FormatAge(DateTime utc) { var age = DateTime.UtcNow - utc; diff --git a/src/Reactor.Cli/Program.cs b/src/Reactor.Cli/Program.cs index 423b189c6..9507bcc95 100644 --- a/src/Reactor.Cli/Program.cs +++ b/src/Reactor.Cli/Program.cs @@ -84,6 +84,11 @@ return Microsoft.UI.Reactor.Cli.Pack.CleanLocalCommand.Run(args.Skip(1).ToArray()); } +if (arg == "templates") +{ + return Microsoft.UI.Reactor.Cli.Templates.TemplatesCommand.Run(args.Skip(1).ToArray()); +} + if (arg == "doctor") { return Microsoft.UI.Reactor.Cli.Doctor.DoctorCommand.Run(args.Skip(1).ToArray()); @@ -133,8 +138,9 @@ void ShowHelp() Console.WriteLine(" check [path] Build and emit one-line diagnostics with skill-file pointers"); Console.WriteLine(" pack-local Pack the in-source framework to /local-nupkgs/ as 0.0.0-local"); Console.WriteLine(" clean-local Remove local packages, NuGet cache entries, and templates"); - Console.WriteLine(" doctor Verify the install (SDK, mur, local feed, template, plugin)"); - Console.WriteLine(" upgrade Re-pack framework + templates and refresh plugin after `git pull`"); + Console.WriteLine(" templates Install the `dotnet new reactor` template pack (Windows App SDK)"); + Console.WriteLine(" doctor Verify the install (SDK, mur, local feed, templates, plugin)"); + Console.WriteLine(" upgrade Re-pack the framework and refresh templates + plugin after `git pull`"); Console.WriteLine(" figma watch Poll a Figma file for design changes"); } diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs new file mode 100644 index 000000000..0d37d012a --- /dev/null +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -0,0 +1,124 @@ +// `mur templates` — manage the `dotnet new` template pack that provides +// `dotnet new reactor`. +// +// Reactor's app templates ship inside the Windows App SDK template pack +// (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`) rather than being built +// from this checkout. `bootstrap.ps1` §5 and `mur upgrade` both route through +// here so there is a single place that knows how to resolve and install it. +// +// Subcommands: +// install Install (or reinstall) the pack. Resolves the newest published +// version — newest stable, else newest prerelease — because +// `dotnet new install` has no --prerelease switch and would +// otherwise fail while the pack is prerelease-only. +// status Report whether the pack is registered. +// +// Flags (install): +// --source Extra NuGet source. Point at a folder of nupkgs to +// test an unpublished build of the pack. +// --version Pin an explicit version instead of resolving. + +namespace Microsoft.UI.Reactor.Cli.Templates; + +public static class TemplatesCommand +{ + public static int Run(string[] args) + { + var sub = args.FirstOrDefault(); + + if (sub is null or "--help" or "-h" or "help") + { + ShowHelp(); + return sub is null ? 1 : 0; + } + + switch (sub) + { + case "install": + return Install(args.Skip(1).ToArray()); + case "status": + return Status(); + default: + Console.Error.WriteLine($"mur templates: unknown subcommand '{sub}'."); + Console.Error.WriteLine(); + ShowHelp(); + return 1; + } + } + + static int Install(string[] args) + { + var source = ParseFlag(args, "--source"); + var version = ParseFlag(args, "--version"); + + Console.WriteLine($"Installing {WinAppSdkTemplates.PackageId} (`dotnet new {WinAppSdkTemplates.BlankShortName}`)"); + + // Resolve a local folder to an absolute path: `dotnet new install` runs + // with its own working directory and won't see a relative one. + if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) + source = Path.GetFullPath(source!); + + var rc = WinAppSdkTemplates.Install(Directory.GetCurrentDirectory(), source, version); + if (rc != 0) + { + Console.Error.WriteLine(); + Console.Error.WriteLine($"mur templates install: `dotnet new install` failed (exit {rc})."); + Console.Error.WriteLine(" To install an unpublished build, pass a folder of nupkgs:"); + Console.Error.WriteLine(" mur templates install --source "); + Console.Error.WriteLine(" To pin an explicit version:"); + Console.Error.WriteLine(" mur templates install --version "); + return rc; + } + + Console.WriteLine(); + Console.WriteLine($"Installed. Scaffold an app with:"); + foreach (var name in WinAppSdkTemplates.ShortNames) + Console.WriteLine($" dotnet new {name} -n MyApp"); + return 0; + } + + static int Status() + { + var installed = WinAppSdkTemplates.IsInstalled(); + if (installed is null) + { + Console.Error.WriteLine("mur templates status: could not enumerate installed `dotnet new` template packages."); + return 1; + } + + if (installed.Value) + { + Console.WriteLine($"{WinAppSdkTemplates.PackageId} is installed (`dotnet new {WinAppSdkTemplates.BlankShortName}`)."); + return 0; + } + + Console.WriteLine($"{WinAppSdkTemplates.PackageId} is NOT installed. Run `mur templates install`."); + return 1; + } + + static void ShowHelp() + { + Console.WriteLine("Usage: mur templates [options]"); + Console.WriteLine(); + Console.WriteLine($"Manages {WinAppSdkTemplates.PackageId}, the Windows App SDK"); + Console.WriteLine($"`dotnet new` pack that provides `dotnet new {WinAppSdkTemplates.BlankShortName}` and friends."); + Console.WriteLine(); + Console.WriteLine("Subcommands:"); + Console.WriteLine(" install Install or reinstall the template pack"); + Console.WriteLine(" status Report whether the pack is registered"); + Console.WriteLine(); + Console.WriteLine("Options (install):"); + Console.WriteLine(" --source Extra NuGet source; use a folder of nupkgs to test an unpublished build"); + Console.WriteLine(" --version Pin an explicit version instead of resolving the newest published one"); + } + + static string? ParseFlag(string[] args, string name) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], name, StringComparison.Ordinal)) + return args[i + 1]; + } + return null; + } +} diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs new file mode 100644 index 000000000..e8321c7a4 --- /dev/null +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -0,0 +1,285 @@ +// The Windows App SDK `dotnet new` template pack, which is where Reactor's app +// templates now live. +// +// Reactor used to ship its own `Microsoft.UI.Reactor.ProjectTemplates` pack +// (`dotnet new reactorapp`, unpackaged). The Windows App SDK template pack now +// carries first-class Reactor templates alongside the WinUI 3 XAML ones, so +// that's what `bootstrap.ps1` installs and what `mur doctor` looks for. The +// legacy pack is still built by `mur pack-local` and published from the release +// workflow, but nothing installs it automatically any more. +// +// Two user-visible differences from the legacy `reactorapp` template: +// • the short name is `reactor` (plus `reactor-mvu` / `reactor-navview` / +// `reactor-tabview` for the richer shells), and +// • scaffolded apps are **packaged** (single-project MSIX) rather than +// unpackaged, so `dotnet run` launches them with package identity. +// +// Why this resolves a version instead of just installing the bare package id: +// `dotnet new install ` has no `--prerelease` switch and resolves +// stable-only, so it fails outright while the pack is publishing prereleases. +// We query the NuGet flat-container index ourselves and install an explicit +// `::`, preferring the newest stable and falling back to the newest +// prerelease — so a fresh clone works against whatever is published today. +// +// Why `--force` is used sparingly: `dotnet new install --force` uninstalls the +// existing package *before* downloading the replacement, so a failed install +// leaves the machine with no templates at all. (Observed: `--force` with a bare +// package id that has no stable version uninstalled the working prerelease and +// then failed with "the package does not exist".) We therefore only pass +// `--force` when replacing an install with a version we already know exists. + +using System.Diagnostics; +using System.Net.Http; +using Microsoft.UI.Reactor.Cli.Pack; + +namespace Microsoft.UI.Reactor.Cli.Templates; + +public static class WinAppSdkTemplates +{ + /// NuGet id of the template pack that ships the Reactor templates. + public const string PackageId = "Microsoft.WindowsAppSDK.WinUI.CSharp.Templates"; + + /// `dotnet new` short name of the blank Reactor template. + public const string BlankShortName = "reactor"; + + /// Every Reactor short name the pack registers. + public static readonly string[] ShortNames = + [ + "reactor", + "reactor-mvu", + "reactor-navview", + "reactor-tabview", + ]; + + // Lists every published version (including prereleases) as a JSON string array. + const string FlatContainerIndexUrl = + "https://api.nuget.org/v3-flatcontainer/microsoft.windowsappsdk.winui.csharp.templates/index.json"; + + /// + /// True when the template pack is registered with the `dotnet new` engine. + /// Returns null when the installed-package list could not be enumerated at + /// all (no `dotnet` on PATH, engine error) so callers can distinguish + /// "definitely missing" from "couldn't tell". + /// + public static bool? IsInstalled() + { + // `dotnet new uninstall` with no arguments lists installed template + // *packages* by id. `dotnet new list` would only show template short + // names, which can't tell our legacy `reactorapp` pack apart from the + // Windows App SDK one when both happen to be installed. + var output = RunCapture("new", "uninstall"); + if (output is null) return null; + return output.Contains(PackageId, StringComparison.OrdinalIgnoreCase); + } + + /// + /// The installed version of the template pack, or null when it isn't + /// installed (or the listing couldn't be read). + /// + public static string? GetInstalledVersion() + { + var output = RunCapture("new", "uninstall"); + if (output is null) return null; + + // The listing indents each package id, then its metadata: + // Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + // Version: 0.0.6-alpha + var lines = output.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + if (!lines[i].Trim().Equals(PackageId, StringComparison.OrdinalIgnoreCase)) + continue; + + for (var j = i + 1; j < lines.Length && j <= i + 4; j++) + { + var trimmed = lines[j].Trim(); + if (trimmed.StartsWith("Version:", StringComparison.OrdinalIgnoreCase)) + return trimmed["Version:".Length..].Trim(); + } + return null; + } + return null; + } + + /// + /// Installs (or updates) the template pack. + /// + /// Working directory for the `dotnet` process. + /// Extra NuGet source — a local folder holding the nupkg, or a feed URL. This is how an unpublished build gets tested. + /// Explicit version to pin. When omitted the newest published version is resolved. + public static int Install(string workingDirectory, string? source = null, string? version = null) + { + var installed = GetInstalledVersion(); + var target = string.IsNullOrWhiteSpace(version) ? ResolveLatestVersion(source) : version!.Trim(); + + // `dotnet new install --force` uninstalls the existing package *before* + // downloading the replacement, so a failed install leaves the machine + // with no templates at all. Never take that path unless we have a + // concrete version we know exists (resolved from the live NuGet index or + // from a nupkg filename on disk). Without one, keep what's installed. + if (target is null) + { + if (installed is not null) + { + Console.Error.WriteLine( + $" warning: could not resolve a published version of {PackageId}; " + + $"keeping the installed {installed}. Re-run with network access, or pass an explicit version."); + return 0; + } + + // Nothing installed and nothing resolved — try a plain install (no + // --force, so there is nothing to lose) and let NuGet report why. + Console.WriteLine($" dotnet new install {PackageId}"); + return Run(workingDirectory, "new", "install", PackageId); + } + + if (installed is not null && + string.Equals(installed, target, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(source)) + { + Console.WriteLine($" Already installed: {PackageId} {installed}"); + return 0; + } + + Console.WriteLine(installed is null + ? $" Installing {PackageId} {target}" + : $" Updating {PackageId} {installed} → {target}"); + + // `::` is `dotnet new install`'s explicit-version syntax and + // the only way to reach a prerelease — a bare id resolves stable-only. + var args = new List { "new", "install", $"{PackageId}::{target}" }; + + // --force is required to replace an existing install; skip it otherwise + // so a first-time install can never uninstall anything. + if (installed is not null) + args.Add("--force"); + + if (!string.IsNullOrWhiteSpace(source)) + { + args.Add("--add-source"); + args.Add(source!); + } + + Console.WriteLine($" dotnet {string.Join(' ', args)}"); + var rc = Run(workingDirectory, args.ToArray()); + if (rc != 0 && installed is not null) + { + Console.Error.WriteLine( + $" warning: the update failed and `dotnet new install --force` removes the old package first, " + + $"so {PackageId} may no longer be installed. Restore it with: " + + $"dotnet new install {PackageId}::{installed}"); + } + return rc; + } + + /// + /// Newest published version of the pack — the newest stable when one exists, + /// otherwise the newest prerelease. Returns null when nothing could be + /// resolved (offline, unreachable feed, empty folder). + /// + public static string? ResolveLatestVersion(string? source = null) + { + // A local folder source is the unpublished-build test path: read the + // versions straight off the nupkg filenames rather than hitting NuGet. + if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) + return SelectPreferStable(EnumerateLocalVersions(source!)); + + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var json = http.GetStringAsync(FlatContainerIndexUrl).GetAwaiter().GetResult(); + return SelectPreferStable(PackLocalCommand.ParseFlatContainerVersions(json)); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $" warning: could not query NuGet for {PackageId} versions " + + $"({ex.GetType().Name}: {ex.Message}); falling back to the default resolution."); + return null; + } + } + + /// + /// Highest stable version, or the highest prerelease when no stable exists. + /// Split out (and internal) so the preference rule is unit-testable without + /// a network round-trip. + /// + internal static string? SelectPreferStable(IEnumerable versions) + { + var all = versions as IReadOnlyList ?? versions.ToList(); + // A '-' after the core triple marks a SemVer prerelease (1.2.3-alpha). + var stable = all.Where(v => !string.IsNullOrWhiteSpace(v) && !v.Contains('-')).ToList(); + return PackLocalCommand.SelectLatestVersion(stable.Count > 0 ? stable : all); + } + + // "..nupkg" → "", case-insensitively. + internal static IReadOnlyList EnumerateLocalVersions(string folder) + { + var prefix = PackageId + "."; + var versions = new List(); + try + { + foreach (var file in Directory.EnumerateFiles(folder, $"{PackageId}.*.nupkg")) + { + var name = Path.GetFileNameWithoutExtension(file); + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + versions.Add(name[prefix.Length..]); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($" warning: could not enumerate '{folder}' ({ex.GetType().Name}: {ex.Message})."); + } + return versions; + } + + static int Run(string workingDirectory, params string[] arguments) + { + var psi = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, + WorkingDirectory = workingDirectory, + }; + foreach (var a in arguments) psi.ArgumentList.Add(a); + + try + { + using var proc = Process.Start(psi); + if (proc is null) return 1; + proc.WaitForExit(); + return proc.ExitCode; + } + catch (Exception ex) + { + Console.Error.WriteLine($" failed to run `dotnet {string.Join(' ', arguments)}`: {ex.Message}"); + return 1; + } + } + + static string? RunCapture(params string[] arguments) + { + var psi = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var a in arguments) psi.ArgumentList.Add(a); + + try + { + using var proc = Process.Start(psi); + if (proc is null) return null; + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + // `dotnet new uninstall` exits non-zero when nothing is installed + // while still printing a usable listing, so don't gate on ExitCode. + return stdout + stderr; + } + catch + { + return null; + } + } +} diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index 507121422..c1c1c3863 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -3,8 +3,9 @@ // Re-runs the source-side steps of bootstrap.ps1: // 1. Re-pack the framework + ProjectTemplates into local-nupkgs/ // (delegates to `mur pack-local`). -// 2. Reinstall the `dotnet new reactorapp` template (uninstall first so the -// template engine drops its cached copy). +// 2. Make sure the Windows App SDK `dotnet new` template pack (which ships +// `dotnet new reactor`) is installed — self-healing for a checkout that +// was bootstrapped before the templates moved there. // 3. Refresh the Claude Code plugin install. // 4. Rebuild + reinstall the Reactor VS preview extension (best-effort — // skipped if VS / the VSIX-dev workload aren't installed, same probe @@ -17,6 +18,7 @@ using System.Diagnostics; using Microsoft.UI.Reactor.Cli.Pack; +using Microsoft.UI.Reactor.Cli.Templates; namespace Microsoft.UI.Reactor.Cli.Upgrade; @@ -44,25 +46,27 @@ public static int Run(string[] args) return rc; } - // 2. Reinstall the dotnet new template. Uninstall first so the template - // engine drops the cached version by id (the installer otherwise wins - // against a same-id repack — see getting-started.md caveat). + // 2. Make sure the `dotnet new reactor` templates are available. They ship + // in the Windows App SDK template pack rather than being built from this + // checkout, so `git pull` never invalidates them — this is a self-healing + // install-if-missing, not a reinstall. Best-effort: a developer who + // scaffolds by hand shouldn't have `mur upgrade` fail on a NuGet hiccup. + // + // The legacy in-repo `Microsoft.UI.Reactor.ProjectTemplates` pack + // (`dotnet new reactorapp`) is still repacked by step 1, but is + // deliberately not installed. Install it manually if you want it: + // dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg Console.WriteLine(); - Console.WriteLine("==> Reinstalling `dotnet new reactorapp` template"); - var feed = Path.Combine(repoRoot, "local-nupkgs"); - var templateNupkg = Path.Combine(feed, $"Microsoft.UI.Reactor.ProjectTemplates.{PackLocalCommand.DefaultLocalVersion}.nupkg"); - if (!File.Exists(templateNupkg)) - { - Console.Error.WriteLine($"mur upgrade: template nupkg not found at {templateNupkg} after pack-local."); - return 1; - } - // Uninstall is best-effort: non-zero exit just means it wasn't installed. - RunDotnet(repoRoot, ignoreExitCode: true, "new", "uninstall", CleanLocalCommand.TemplatePackageId); - rc = RunDotnet(repoRoot, ignoreExitCode: false, "new", "install", templateNupkg); - if (rc != 0) + var templateSource = ParseFlag(args, "--templates-source"); + var templateVersion = ParseFlag(args, "--templates-version"); + Console.WriteLine($"==> Checking `dotnet new {WinAppSdkTemplates.BlankShortName}` templates ({WinAppSdkTemplates.PackageId})"); + // Install() is a no-op when the resolved version is already installed, and + // deliberately leaves an existing install alone when it can't resolve a + // newer one — so this is safe to run on every upgrade. + var templateRc = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion); + if (templateRc != 0) { - Console.Error.WriteLine("mur upgrade: template install failed."); - return rc; + Console.Error.WriteLine($" Could not install {WinAppSdkTemplates.PackageId} (exit {templateRc}); the rest of the upgrade completed."); } // 3. Refresh Claude plugin (best-effort; not every user has Claude Code). @@ -129,6 +133,7 @@ public static int Run(string[] args) Console.WriteLine(); Console.WriteLine("Upgrade complete."); Console.WriteLine(); + var feed = Path.Combine(repoRoot, "local-nupkgs"); Console.WriteLine(" To bump `mur` itself (which can't update its own running process), run:"); Console.WriteLine($" dotnet tool update -g --add-source \"{feed}\" Microsoft.UI.Reactor.Cli"); Console.WriteLine(" Or just re-run ./bootstrap.ps1 from the repo root."); @@ -278,38 +283,14 @@ static void TryReinstallVsExtension(string repoRoot) return null; } - static int RunDotnet(string workingDirectory, bool ignoreExitCode, params string[] arguments) + static string? ParseFlag(string[] args, string name) { - var psi = new ProcessStartInfo("dotnet") - { - UseShellExecute = false, - WorkingDirectory = workingDirectory, - }; - foreach (var a in arguments) psi.ArgumentList.Add(a); - - Process? proc; - try + for (var i = 0; i < args.Length - 1; i++) { - proc = Process.Start(psi); - } - catch (Exception ex) - { - if (ignoreExitCode) return 0; - Console.Error.WriteLine($"mur upgrade: failed to start `dotnet {string.Join(' ', arguments)}`: {ex.Message}"); - Console.Error.WriteLine(" Verify .NET 10+ is installed and `dotnet` resolves on PATH."); - return 1; - } - if (proc is null) - { - if (ignoreExitCode) return 0; - Console.Error.WriteLine($"mur upgrade: `dotnet {string.Join(' ', arguments)}` did not start (Process.Start returned null)."); - return 1; - } - using (proc) - { - proc.WaitForExit(); - return ignoreExitCode ? 0 : proc.ExitCode; + if (string.Equals(args[i], name, StringComparison.Ordinal)) + return args[i + 1]; } + return null; } static void CopyDirectory(string src, string dst) diff --git a/tests/Reactor.Tests/TemplateMetadataTests.cs b/tests/Reactor.Tests/TemplateMetadataTests.cs index 3f718f994..5389c792d 100644 --- a/tests/Reactor.Tests/TemplateMetadataTests.cs +++ b/tests/Reactor.Tests/TemplateMetadataTests.cs @@ -1,6 +1,13 @@ -// Repository-content validation for project-template metadata. +// Repository-content validation for the legacy project-template metadata. // -// The bug this test was added against: +// These guard `tools/Templates/` — the in-repo `Microsoft.UI.Reactor.ProjectTemplates` +// pack that provides `dotnet new reactorapp`. That pack is still built by +// `mur pack-local` and published by the release workflow, but as of the move to +// the Windows App SDK template pack it is no longer installed by `bootstrap.ps1`. +// `dotnet new reactor` (packaged, from Microsoft.WindowsAppSDK.WinUI.CSharp.Templates) +// is the supported scaffolding path; see WinAppSdkTemplatesTests. +// +// The bug this file was originally added against: // `tools/Templates/templates/WinUIApp-CSharp/.template.config/template.json` // shipped with `identity` = "Micrsoft.UI.Reactor.CSharp" (missing the // second 'o') from at least Phase 1 onward. The existing integration test @@ -114,11 +121,11 @@ public void ReleaseWorkflow_stamps_framework_version_into_templates_pack() [Fact] public void Bootstrap_packs_templates_with_latest_framework_version() { - // The local side of the same fix: bootstrap.ps1 must pack the templates with - // `--framework-version latest` so a fresh clone's `dotnet new reactorapp` - // tracks the newest published package instead of a hand-maintained csproj - // default. Fails the instant that wiring is dropped from either invocation - // path (installed `mur` or the `dotnet run` fallback). + // The local side of the same fix: bootstrap.ps1 must pack the legacy + // templates with `--framework-version latest` so the ProjectTemplates + // nupkg tracks the newest published package instead of a hand-maintained + // csproj default. Fails the instant that wiring is dropped from either + // invocation path (installed `mur` or the `dotnet run` fallback). var (path, text) = ReadRepoFile("bootstrap.ps1"); var normalized = text.Replace("\r\n", "\n"); var matches = global::System.Text.RegularExpressions.Regex.Matches( @@ -126,10 +133,58 @@ public void Bootstrap_packs_templates_with_latest_framework_version() Assert.True( matches.Count >= 2, $"'{path}' must invoke `mur pack-local --framework-version latest` on both the installed-`mur` " + - $"and `dotnet run` fallback paths so local scaffolds track the latest published framework " + + $"and `dotnet run` fallback paths so the packed templates track the latest published framework " + $"(found {matches.Count}, expected >= 2). Dropping it re-introduces the drift fixed for issue #866."); } + // ── Template-pack migration guard ────────────────────────────────────── + // + // Reactor's app templates moved into the Windows App SDK `dotnet new` pack + // (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, short name `reactor`). + // bootstrap.ps1 installs *that* pack and deliberately no longer installs the + // in-repo `Microsoft.UI.Reactor.ProjectTemplates` one. These two tests pin + // both halves of that contract — the regression they guard is silent + // (a bootstrap that quietly re-registers `reactorapp` would hand new + // developers the unpackaged template the docs no longer describe). + + [Fact] + public void Bootstrap_installs_the_windows_app_sdk_template_pack() + { + var (path, text) = ReadRepoFile("bootstrap.ps1"); + Assert.Contains("Microsoft.WindowsAppSDK.WinUI.CSharp.Templates", text, StringComparison.Ordinal); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch( + text.Replace("\r\n", "\n"), @"templates',\s*'install'"), + $"'{path}' must install the Reactor templates via `mur templates install`, which resolves the " + + "newest published version of the Windows App SDK template pack. `dotnet new install` has no " + + "--prerelease switch and resolves stable-only, so installing the bare package id fails while " + + "the pack is prerelease-only."); + } + + [Fact] + public void Bootstrap_does_not_install_the_legacy_reactorapp_template() + { + // `mur pack-local` still *builds* Microsoft.UI.Reactor.ProjectTemplates + // and the release workflow still publishes it — but nothing in bootstrap + // may hand it to `dotnet new install`, or a fresh clone silently gets the + // legacy unpackaged `reactorapp` template back. + var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); + + // Strip comment lines: the step deliberately documents the manual + // opt-in command, and that mention must not trip this guard. + var code = string.Join('\n', normalized + .Split('\n') + .Where(line => !line.TrimStart().StartsWith("#", StringComparison.Ordinal))); + + Assert.False( + global::System.Text.RegularExpressions.Regex.IsMatch( + code, @"new\s+install.*Microsoft\.UI\.Reactor\.ProjectTemplates"), + $"'{path}' must not `dotnet new install` Microsoft.UI.Reactor.ProjectTemplates — the Reactor " + + "templates now ship in the Windows App SDK template pack (`dotnet new reactor`). Install the " + + "legacy pack by hand if you specifically need the unpackaged `reactorapp` shape."); + } + // Returns the text of the YAML step whose `name:` equals stepName (the slice // from that step's `- name:` line up to the next `- name:` line or EOF), or // null if no such step exists. Deliberately simple line scanning — enough to diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs new file mode 100644 index 000000000..3096597ce --- /dev/null +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -0,0 +1,205 @@ +// Unit coverage for the Windows App SDK `dotnet new` template pack helper +// (Templates/WinAppSdkTemplates.cs). +// +// Reactor's app templates moved out of this repo's own +// `Microsoft.UI.Reactor.ProjectTemplates` pack (`dotnet new reactorapp`) and +// into `Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` (`dotnet new reactor`). +// bootstrap.ps1 §5, `mur upgrade`, and `mur doctor` all route through this +// helper. +// +// The load-bearing piece worth unit-testing is version *selection*. The install +// path can't just hand `dotnet new install` a bare package id: that command has +// no `--prerelease` switch and resolves stable-only, so it fails outright while +// the pack is publishing prereleases (it shipped 0.0.6-alpha before any stable). +// So we resolve a version ourselves and pass `::`. These tests pin +// the two rules that resolution has to get right: +// 1. prefer a stable version when one exists, and +// 2. otherwise fall back to the *newest* prerelease — numerically, not +// lexically ("alpha.10" > "alpha.9"). +// +// The network fetch and the `dotnet new install` invocation are not unit-tested +// (they hit nuget.org and the template engine); `mur templates status` and the +// bootstrap CI job cover those. + +using Microsoft.UI.Reactor.Cli.Templates; +using Xunit; + +namespace Microsoft.UI.Reactor.Tests; + +public sealed class WinAppSdkTemplatesTests +{ + [Fact] + public void PackageId_is_the_windows_app_sdk_template_pack() + { + // Anchors the identity bootstrap.ps1 installs and `mur doctor` probes for. + // A rename here silently turns the doctor check into a permanent FAIL. + Assert.Equal("Microsoft.WindowsAppSDK.WinUI.CSharp.Templates", WinAppSdkTemplates.PackageId); + } + + [Fact] + public void ShortNames_cover_every_reactor_template_in_the_pack() + { + // The four short names the pack registers (microsoft/WindowsAppSDK#6620). + // `reactor` is the blank one users are pointed at first. + Assert.Equal( + new[] { "reactor", "reactor-mvu", "reactor-navview", "reactor-tabview" }, + WinAppSdkTemplates.ShortNames); + Assert.Equal("reactor", WinAppSdkTemplates.BlankShortName); + Assert.Contains(WinAppSdkTemplates.BlankShortName, WinAppSdkTemplates.ShortNames); + } + + [Fact] + public void SelectPreferStable_prefers_a_stable_over_a_higher_prerelease() + { + // A plain "highest SemVer" pick returns 1.1.0-alpha.1 here because its + // core triple is higher. For a developer bootstrap we want the shipped + // stable instead. + var published = new[] { "1.0.0", "1.1.0-alpha.1", "0.9.0" }; + + Assert.Equal("1.0.0", WinAppSdkTemplates.SelectPreferStable(published)); + } + + [Fact] + public void SelectPreferStable_falls_back_to_newest_prerelease_when_no_stable_exists() + { + // The state the pack was actually in when this migration landed: only + // prereleases published. Returning null here would make bootstrap fall + // back to a bare package id, which `dotnet new install` then fails to + // resolve (stable-only) — the exact breakage this logic prevents. + var published = new[] { "0.0.4-alpha", "0.0.6-alpha", "0.0.5-alpha" }; + + Assert.Equal("0.0.6-alpha", WinAppSdkTemplates.SelectPreferStable(published)); + } + + [Fact] + public void SelectPreferStable_orders_prereleases_numerically_not_lexically() + { + // A string sort ranks "alpha.9" above "alpha.10". Pinning the older + // template pack is a silent downgrade, not a hard failure, so assert it. + var published = new[] { "0.0.6-alpha.9", "0.0.6-alpha.10", "0.0.6-alpha.2" }; + + var latest = WinAppSdkTemplates.SelectPreferStable(published); + + Assert.Equal("0.0.6-alpha.10", latest); + Assert.NotEqual("0.0.6-alpha.9", latest); + } + + [Fact] + public void SelectPreferStable_returns_null_for_no_versions() + { + // Empty feed / unreachable index. Callers treat null as "couldn't + // resolve" and fall back to the bare package id rather than installing + // a bogus "id::" spec. + Assert.Null(WinAppSdkTemplates.SelectPreferStable(Array.Empty())); + } + + [Fact] + public void EnumerateLocalVersions_reads_versions_off_nupkg_filenames() + { + // The `-WinAppSdkTemplatesSource ` path used to test an + // unpublished build of the pack: resolution reads the folder rather + // than querying NuGet. + var dir = global::System.IO.Path.Combine( + global::System.IO.Path.GetTempPath(), + $"wasdk-templates-{Guid.NewGuid():N}"); + global::System.IO.Directory.CreateDirectory(dir); + try + { + var id = WinAppSdkTemplates.PackageId; + global::System.IO.File.WriteAllText(global::System.IO.Path.Combine(dir, $"{id}.0.0.6-alpha.nupkg"), ""); + global::System.IO.File.WriteAllText(global::System.IO.Path.Combine(dir, $"{id}.0.0.7-alpha.nupkg"), ""); + // An unrelated package in the same folder must not be picked up. + global::System.IO.File.WriteAllText(global::System.IO.Path.Combine(dir, "Microsoft.UI.Reactor.9.9.9.nupkg"), ""); + + var versions = WinAppSdkTemplates.EnumerateLocalVersions(dir); + + Assert.Equal(2, versions.Count); + Assert.Contains("0.0.6-alpha", versions); + Assert.Contains("0.0.7-alpha", versions); + Assert.DoesNotContain("9.9.9", versions); + Assert.Equal("0.0.7-alpha", WinAppSdkTemplates.SelectPreferStable(versions)); + } + finally + { + try { global::System.IO.Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + } + } + + [Fact] + public void EnumerateLocalVersions_returns_empty_for_a_missing_folder() + { + var missing = global::System.IO.Path.Combine( + global::System.IO.Path.GetTempPath(), + $"wasdk-templates-missing-{Guid.NewGuid():N}"); + + Assert.Empty(WinAppSdkTemplates.EnumerateLocalVersions(missing)); + } + + // ── Destructive-install guard ────────────────────────────────────────── + // + // Observed for real during this migration: `dotnet new install --force` + // uninstalls the existing package *before* downloading the replacement. With + // a bare package id (no version) and only prereleases published, NuGet then + // reported "the package does not exist" — and the machine was left with **no** + // templates installed at all. Exit code 103, working install destroyed. + // + // The install path therefore must never combine `--force` with a spec it + // hasn't confirmed exists. These tests pin the two properties that prevent it. + + [Fact] + public void ResolveLatestVersion_returns_null_for_an_empty_local_source() + { + // This is the input that produced the destructive case: nothing resolvable. + // Returning null is what lets Install() choose the non-destructive branch, + // so a null here is load-bearing, not an edge case. + var dir = global::System.IO.Path.Combine( + global::System.IO.Path.GetTempPath(), + $"wasdk-templates-empty-{Guid.NewGuid():N}"); + global::System.IO.Directory.CreateDirectory(dir); + try + { + Assert.Null(WinAppSdkTemplates.ResolveLatestVersion(dir)); + } + finally + { + try { global::System.IO.Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + } + } + + [Fact] + public void Install_never_pairs_force_with_an_unresolved_package_spec() + { + // Source-level guard. Install() shells out to `dotnet new`, so driving it + // for real would mutate the developer's machine — exactly the damage being + // guarded against. Instead assert the invariant on the source: every + // "--force" must be added on a path that has a concrete version, and the + // bare-id install (the `target is null` fallback) must not add --force. + var (path, text) = ReadCliSource(); + + // The bare-id fallback line — the one that runs when no version resolved. + Assert.Contains("\"new\", \"install\", PackageId)", text.Replace("\r\n", "\n")); + Assert.DoesNotContain("\"new\", \"install\", PackageId, \"--force\"", text); + + // --force must be conditional on something already being installed. + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch( + text.Replace("\r\n", "\n"), + @"if \(installed is not null\)\s*\n\s*args\.Add\(""--force""\);"), + $"'{path}' must only add --force when replacing an existing install. " + + "`dotnet new install --force` uninstalls before downloading, so pairing it with a spec " + + "that may not resolve destroys a working template install (exit 103)."); + } + + static (string path, string text) ReadCliSource() + { + var dir = AppContext.BaseDirectory; + while (dir != null && !global::System.IO.File.Exists(global::System.IO.Path.Combine(dir, "Reactor.slnx"))) + dir = global::System.IO.Path.GetDirectoryName(dir); + Assert.NotNull(dir); + + var path = global::System.IO.Path.Combine( + dir!, "src", "Reactor.Cli", "Templates", "WinAppSdkTemplates.cs"); + Assert.True(global::System.IO.File.Exists(path), $"Expected '{path}' to exist; file moved or renamed?"); + return (path, global::System.IO.File.ReadAllText(path)); + } +} diff --git a/tools/Templates/README.md b/tools/Templates/README.md index 4b2811378..b7ead78eb 100644 --- a/tools/Templates/README.md +++ b/tools/Templates/README.md @@ -2,6 +2,25 @@ **`dotnet new` templates for scaffolding [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) apps — a ready-to-run WinUI 3 Reactor project in one command.** +> [!IMPORTANT] +> **This package is superseded.** The recommended way to scaffold a Reactor app +> is now the official Windows App SDK template pack, which ships first-class +> Reactor templates alongside the WinUI 3 XAML ones: +> +> ```shell +> dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +> dotnet new reactor -n MyApp +> ``` +> +> Short names there: `reactor`, `reactor-mvu`, `reactor-navview`, +> `reactor-tabview`. Those templates scaffold **packaged** (single-project MSIX) +> apps, so `dotnet run` launches with full package identity. +> +> This package is still built and published for the **unpackaged** +> (`WindowsPackageType=None`) shape, but `bootstrap.ps1` no longer installs it +> and it is no longer the documented default. Prefer `dotnet new reactor` unless +> you specifically need the unpackaged, zip-and-go project layout. + ## About This package installs project templates for the .NET CLI and Visual Studio so you can create a new declarative WinUI 3 desktop app powered by Reactor without wiring up the project by hand. From 55cc443bbe54b773cec8257520a8899c02368ac1 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:18:12 -0700 Subject: [PATCH 02/37] fix(cli): report what `mur templates install` actually did Found by running the command against the real published template pack (0.0.7-alpha) with NuGet unreachable. The non-destructive guard worked correctly -- it kept the installed pack and uninstalled nothing -- but the command still printed "Installed.", telling the user an install had happened when none had. A bare exit code cannot express the difference: "kept the existing install because nothing could be resolved" is a success for exit-code purposes but is not an install. `Install` now returns an `InstallOutcome` (Installed / Updated / AlreadyCurrent / KeptExisting / Failed) and the callers report the real outcome. Also adds the credential-provider hint to the failure path: `dotnet new install` does not use the NuGet credential provider, so an authenticated feed reports "the package does not exist" -- indistinguishable from the package genuinely not being published. That cost real time today. Verified against the live pack, all three non-failure paths: no resolution + installed -> "Kept the existing install" --version 0.0.7-alpha -> "Already up to date" (failure path unchanged) Tests: outcome enum completeness, plus a source-level guard that the command does not unconditionally print "Installed." -- the bug was in the reporting, so asserting on Install() alone would have missed it. Full suite 14,152 passed / 0 failed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- src/Reactor.Cli/Templates/TemplatesCommand.cs | 31 +++++++++++-- .../Templates/WinAppSdkTemplates.cs | 46 +++++++++++++++---- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 6 +-- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 36 +++++++++++++++ 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index 1421ec20f..b28200ff8 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -58,20 +58,41 @@ static int Install(string[] args) if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) source = Path.GetFullPath(source!); - var rc = WinAppSdkTemplates.Install(Directory.GetCurrentDirectory(), source, version); - if (rc != 0) + var outcome = WinAppSdkTemplates.Install(Directory.GetCurrentDirectory(), source, version); + if (outcome == WinAppSdkTemplates.InstallOutcome.Failed) { Console.Error.WriteLine(); - Console.Error.WriteLine($"mur templates install: `dotnet new install` failed (exit {rc})."); + Console.Error.WriteLine($"mur templates install: `dotnet new install` failed."); Console.Error.WriteLine(" To install an unpublished build, pass a folder of nupkgs:"); Console.Error.WriteLine(" mur templates install --source "); Console.Error.WriteLine(" To pin an explicit version:"); Console.Error.WriteLine(" mur templates install --version "); - return rc; + Console.Error.WriteLine(" Note: `dotnet new install` does not use the NuGet credential provider, so an"); + Console.Error.WriteLine(" authenticated feed reports \"the package does not exist\". Restore the package"); + Console.Error.WriteLine(" first, then pass the cached .nupkg path to --source."); + return 1; } + // Don't claim an install happened when the existing pack was simply kept + // or was already current — the user needs to know whether anything moved. Console.WriteLine(); - Console.WriteLine($"Installed. Scaffold an app with:"); + switch (outcome) + { + case WinAppSdkTemplates.InstallOutcome.KeptExisting: + Console.WriteLine("Kept the existing install (could not resolve a published version)."); + break; + case WinAppSdkTemplates.InstallOutcome.AlreadyCurrent: + Console.WriteLine("Already up to date."); + break; + case WinAppSdkTemplates.InstallOutcome.Updated: + Console.WriteLine("Updated."); + break; + default: + Console.WriteLine("Installed."); + break; + } + + Console.WriteLine("Scaffold an app with:"); foreach (var name in WinAppSdkTemplates.ShortNames) Console.WriteLine($" dotnet new {name} -n MyApp"); return 0; diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 6102a9816..2ee342c26 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -146,13 +146,33 @@ internal static bool InterpretTemplateListOutput(string output) return null; } + /// What an call actually did. + public enum InstallOutcome + { + /// The pack was not present and is now installed. + Installed, + /// An older pack was replaced with a newer one. + Updated, + /// The resolved version was already installed; nothing changed. + AlreadyCurrent, + /// No version could be resolved, so the existing install was left untouched. + KeptExisting, + /// The install was attempted and failed. + Failed, + } + /// /// Installs (or updates) the template pack. /// /// Working directory for the `dotnet` process. /// Extra NuGet source — a local folder holding the nupkg, or a feed URL. This is how an unpublished build gets tested. /// Explicit version to pin. When omitted the newest published version is resolved. - public static int Install(string workingDirectory, string? source = null, string? version = null) + /// + /// Returns what actually happened rather than a bare exit code: "kept the + /// existing install because nothing could be resolved" is a success for + /// exit-code purposes but must not be reported to the user as "installed". + /// + public static InstallOutcome Install(string workingDirectory, string? source = null, string? version = null) { var installed = GetInstalledVersion(); var target = string.IsNullOrWhiteSpace(version) ? ResolveLatestVersion(source) : version!.Trim(); @@ -169,13 +189,15 @@ public static int Install(string workingDirectory, string? source = null, string Console.Error.WriteLine( $" warning: could not resolve a published version of {PackageId}; " + $"keeping the installed {installed}. Re-run with network access, or pass an explicit version."); - return 0; + return InstallOutcome.KeptExisting; } // Nothing installed and nothing resolved — try a plain install (no // --force, so there is nothing to lose) and let NuGet report why. Console.WriteLine($" dotnet new install {PackageId}"); - return Run(workingDirectory, "new", "install", PackageId); + return Run(workingDirectory, "new", "install", PackageId) == 0 + ? InstallOutcome.Installed + : InstallOutcome.Failed; } if (installed is not null && @@ -183,7 +205,7 @@ public static int Install(string workingDirectory, string? source = null, string string.IsNullOrWhiteSpace(source)) { Console.WriteLine($" Already installed: {PackageId} {installed}"); - return 0; + return InstallOutcome.AlreadyCurrent; } Console.WriteLine(installed is null @@ -207,14 +229,18 @@ public static int Install(string workingDirectory, string? source = null, string Console.WriteLine($" dotnet {string.Join(' ', args)}"); var rc = Run(workingDirectory, args.ToArray()); - if (rc != 0 && installed is not null) + if (rc != 0) { - Console.Error.WriteLine( - $" warning: the update failed and `dotnet new install --force` removes the old package first, " + - $"so {PackageId} may no longer be installed. Restore it with: " + - $"dotnet new install {PackageId}::{installed}"); + if (installed is not null) + { + Console.Error.WriteLine( + $" warning: the update failed and `dotnet new install --force` removes the old package first, " + + $"so {PackageId} may no longer be installed. Restore it with: " + + $"dotnet new install {PackageId}::{installed}"); + } + return InstallOutcome.Failed; } - return rc; + return installed is null ? InstallOutcome.Installed : InstallOutcome.Updated; } /// diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index c1c1c3863..4e5e10fbe 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -63,10 +63,10 @@ public static int Run(string[] args) // Install() is a no-op when the resolved version is already installed, and // deliberately leaves an existing install alone when it can't resolve a // newer one — so this is safe to run on every upgrade. - var templateRc = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion); - if (templateRc != 0) + var templateOutcome = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion); + if (templateOutcome == WinAppSdkTemplates.InstallOutcome.Failed) { - Console.Error.WriteLine($" Could not install {WinAppSdkTemplates.PackageId} (exit {templateRc}); the rest of the upgrade completed."); + Console.Error.WriteLine($" Could not install {WinAppSdkTemplates.PackageId}; the rest of the upgrade completed."); } // 3. Refresh Claude plugin (best-effort; not every user has Claude Code). diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 3c5921bba..3ac234629 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -249,6 +249,42 @@ public void Doctor_probes_template_availability_not_just_package_presence() Assert.Contains("AreTemplatesAvailable()", text, StringComparison.Ordinal); } + // ── Outcome-reporting guard ──────────────────────────────────────────── + // + // Found by running `mur templates install` against the real published pack + // with NuGet unreachable: the guard correctly kept the installed pack and + // uninstalled nothing, but the command still printed "Installed." — telling + // the user an install had happened when none had. A bare exit code cannot + // express the difference, so Install returns an outcome instead. + + [Fact] + public void InstallOutcome_distinguishes_keeping_an_existing_pack_from_installing() + { + // These four non-failure outcomes are not interchangeable: only two of + // them mean the machine actually changed. Collapsing them back to a + // bool/int is what produced the wrong "Installed." message. + var values = Enum.GetNames(); + foreach (var expected in new[] { "Installed", "Updated", "AlreadyCurrent", "KeptExisting", "Failed" }) + Assert.Contains(expected, values); + } + + [Fact] + public void TemplatesCommand_does_not_report_Installed_for_every_outcome() + { + // Source-level guard on the call site — the bug was in the reporting, + // not the install logic, so asserting on Install() alone would miss it. + var (path, text) = ReadRepoFile(global::System.IO.Path.Combine( + "src", "Reactor.Cli", "Templates", "TemplatesCommand.cs")); + var normalized = text.Replace("\r\n", "\n"); + + Assert.Contains("InstallOutcome.KeptExisting", normalized, StringComparison.Ordinal); + Assert.Contains("Kept the existing install", normalized, StringComparison.Ordinal); + Assert.False( + normalized.Contains("Console.WriteLine($\"Installed. Scaffold an app with:\")", StringComparison.Ordinal), + $"'{path}' must not unconditionally print \"Installed.\" — `mur templates install` reports success when it " + + "deliberately keeps an existing pack (nothing resolved), and claiming an install happened there is wrong."); + } + static (string path, string text) ReadRepoFile(string repoRelativePath) { var path = global::System.IO.Path.Combine(FindRoot(), repoRelativePath); From 66a64cfd96cd94a2ecb88b1ae002b8e3a6127e5b Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:32:02 -0700 Subject: [PATCH 03/37] Remove the in-repo dotnet new template package Deletes `Microsoft.UI.Reactor.ProjectTemplates` (`tools/Templates/`, short name `dotnet new reactorapp`). Reactor's project templates now ship in the official Windows App SDK pack, `Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, which published 0.0.7-alpha today carrying `reactor`, `reactor-mvu`, `reactor-navview` and `reactor-tabview` (microsoft/WindowsAppSDK#6620 + #6786). Verified against the published pack: a scaffold emits Microsoft.UI.Reactor 0.1.0-preview.16 and a Package.appxmanifest. Behaviour change worth calling out: no template produces the **unpackaged** shape any more. The Windows App SDK templates scaffold packaged single-project MSIX apps, so `dotnet run` launches with package identity and needs Developer Mode. For an unpackaged app, scaffold and set WindowsPackageType=None by hand -- the packaging guide documents both shapes. Published versions on NuGet.org are unaffected (NuGet does not allow deletion); they are to be deprecated with a pointer to `dotnet new reactor`. That is a portal action, not something this commit can do. Removed: - tools/Templates/ and its solution + Directory.Build.props references - the `Pack Templates` step in release.yml and the internal ADO pipeline - `mur pack-local`'s templates pack and its `--framework-version` flag (plus the now-dead ResolveTemplateFrameworkVersion helper); the SemVer helpers it shared with `mur templates` are kept and still used - `mur doctor`'s local-template-nupkg check - TemplateMetadataTests and CreateTemplateTests `mur clean-local` deliberately still knows the old package id: dev machines bootstrapped before this change have a stale nupkg and template registration, and cleaning those up is exactly what that command is for. CreateTemplateTests also *contained* TemplatePackageTestFixture, which SourceMapPackageConsumerTests depends on via LocalPackageFeedCollection. Extracted it to LocalPackageFeedFixture (renamed, template plumbing dropped) rather than losing unrelated coverage. Tests: three guards -- bootstrap installs the Windows App SDK pack, bootstrap never installs the removed id, and the repo no longer ships the package. The last asserts on tracked source rather than Directory.Exists, because bin/obj under tools/Templates are gitignored and survive a pull; an existence check would fail for any contributor who had built it. Mutation-verified by resurrecting the csproj. Suite 14,151 passed / 0 failed; Release build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 18 +- .github/workflows/release.yml | 15 - CHANGELOG.md | 19 + Directory.Build.props | 10 +- README.md | 2 +- Reactor.slnx | 3 - bootstrap.ps1 | 36 +- .../templates/reactor-build-steps.yml | 10 +- docs/_pipeline/templates/index.md.dt | 2 +- docs/contributing/release-runbook.md | 47 +-- docs/guide/README.md | 2 +- docs/guide/index.md | 2 +- docs/security/threat-model.md | 1 - .../skills/reactor-getting-started/SKILL.md | 3 +- src/Reactor.Advanced/README.md | 1 - src/Reactor.Cli/Doctor/DoctorCommand.cs | 22 +- src/Reactor.Cli/Pack/CleanLocalCommand.cs | 13 +- src/Reactor.Cli/Pack/PackLocalCommand.cs | 101 +---- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 11 +- src/Reactor.Devtools/README.md | 1 - src/Reactor/README.md | 1 - .../Packaging/CreateTemplateTests.cs | 376 ------------------ .../Packaging/LocalPackageFeedCollection.cs | 2 +- .../Packaging/LocalPackageFeedFixture.cs | 122 ++++++ .../SourceMapPackageConsumerTests.cs | 4 +- .../PackLocalFrameworkVersionTests.cs | 20 - tests/Reactor.Tests/TemplateMetadataTests.cs | 257 ------------ .../Reactor.Tests/VersionSingleSourceTests.cs | 21 - .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 72 ++++ .../Microsoft.UI.Reactor.Templates.csproj | 92 ----- tools/Templates/README.md | 89 ----- .../.template.config/dotnetcli.host.json | 32 -- .../.template.config/ide.host.json | 22 - .../.template.config/template.json | 132 ------ .../templates/WinUIApp-CSharp/App.cs | 49 --- .../WinUIApp-CSharp/Assets/AppIcon.ico | Bin 34494 -> 0 bytes .../Company.ReactorApp1.csproj | 80 ---- .../Properties/launchSettings.json | 22 - 38 files changed, 282 insertions(+), 1430 deletions(-) delete mode 100644 tests/Reactor.IntegrationTests/Packaging/CreateTemplateTests.cs create mode 100644 tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs delete mode 100644 tests/Reactor.Tests/TemplateMetadataTests.cs delete mode 100644 tools/Templates/Microsoft.UI.Reactor.Templates.csproj delete mode 100644 tools/Templates/README.md delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/.template.config/dotnetcli.host.json delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/.template.config/ide.host.json delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/.template.config/template.json delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/App.cs delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/Assets/AppIcon.ico delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj delete mode 100644 tools/Templates/templates/WinUIApp-CSharp/Properties/launchSettings.json diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index c21100756..3a49231a0 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -13,7 +13,6 @@ on: - 'bootstrap.ps1' - 'tools/BootstrapFeedResolver.ps1' - 'src/Reactor.Cli/**' - - 'tools/Templates/**' - 'Directory.Build.props' - 'Directory.Build.targets' - 'Directory.Packages.props' @@ -24,7 +23,6 @@ on: - 'bootstrap.ps1' - 'tools/BootstrapFeedResolver.ps1' - 'src/Reactor.Cli/**' - - 'tools/Templates/**' - 'Directory.Build.props' - 'Directory.Build.targets' - 'Directory.Packages.props' @@ -144,7 +142,6 @@ jobs: run: | $expected = @( 'local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg', - 'local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg', 'local-nupkgs/Microsoft.UI.Reactor.Cli.1.0.0.nupkg' ) $missing = @() @@ -188,20 +185,19 @@ jobs: } Write-Host " [ok] $shortName" } - # The pack itself must be the Windows App SDK one, not our legacy - # in-repo ProjectTemplates package. + # The pack itself must be the Windows App SDK one. $packages = dotnet new uninstall 2>&1 | Out-String if ($packages -notmatch 'Microsoft\.WindowsAppSDK\.WinUI\.CSharp\.Templates') { throw "Microsoft.WindowsAppSDK.WinUI.CSharp.Templates is not installed" } Write-Host " [ok] Microsoft.WindowsAppSDK.WinUI.CSharp.Templates registered" - # bootstrap must NOT install the legacy pack any more. A stray install - # would silently hand new developers the unpackaged `reactorapp` - # template the docs no longer describe. + # The in-repo Microsoft.UI.Reactor.ProjectTemplates pack was deleted. + # Nothing may resurrect it: a stray install would hand new developers + # the unpackaged `reactorapp` template the docs no longer describe. if ($packages -match 'Microsoft\.UI\.Reactor\.ProjectTemplates') { - throw "bootstrap installed the legacy Microsoft.UI.Reactor.ProjectTemplates pack; it should only be packed, not installed" + throw "the removed Microsoft.UI.Reactor.ProjectTemplates pack is installed; it should no longer exist" } - Write-Host " [ok] legacy Microsoft.UI.Reactor.ProjectTemplates not installed" + Write-Host " [ok] removed Microsoft.UI.Reactor.ProjectTemplates not installed" - name: Scaffold a TestApp and restore against the local feed shell: pwsh @@ -223,8 +219,6 @@ jobs: # mur pack-local. The template's normal default may point at a # not-yet-published public preview while a release-prep PR is in # flight, so opt into the local package version explicitly here - # (`--reactor-version` is the Windows App SDK template's knob; - # the legacy `reactorapp` template called it --MSUIReactorVersion). dotnet new reactor -n TestApp --reactor-version 0.0.0-local if ($LASTEXITCODE -ne 0) { throw "dotnet new reactor exited $LASTEXITCODE" } if (-not (Test-Path 'TestApp/TestApp.csproj')) { throw "TestApp/TestApp.csproj not produced" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d65ac0402..d7ff45ef4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,21 +201,6 @@ jobs: -p:Version=${{ steps.version.outputs.version }} -o artifacts/nupkg - - name: Pack Templates - # MicrosoftUIReactorVersion stamps the framework version that generated - # apps reference (baked into template.json by the csproj's BeforePack - # target). Lock it to the version published in this same run so the - # template can never drift behind the framework — same value used for - # the framework/Advanced/Devtools packs above. Without this it silently - # falls back to the csproj's local-dev default. - run: > - dotnet pack tools\Templates\Microsoft.UI.Reactor.Templates.csproj - --no-build --configuration Release - -p:Version=${{ steps.version.outputs.version }} - -p:MicrosoftUIReactorVersion=${{ steps.version.outputs.version }} - -p:Platform=AnyCPU - -o artifacts/nupkg - - name: Build VSIX shell: pwsh run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c660613..4fc877933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,27 @@ Conventions for contributors: ### Deprecated +- **`Microsoft.UI.Reactor.ProjectTemplates` is deprecated on NuGet.org.** Published versions + remain restorable but are marked deprecated with a pointer to `dotnet new reactor`. Use + `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` instead. + ### Removed +- **Removed the in-repo `Microsoft.UI.Reactor.ProjectTemplates` package and its + `dotnet new reactorapp` template** (`tools/Templates/`). Reactor's project templates now ship in + the official Windows App SDK `dotnet new` pack + ([`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.WinUI.CSharp.Templates) + `0.0.7-alpha` and later), which provides `reactor`, `reactor-mvu`, `reactor-navview`, and + `reactor-tabview` (microsoft/WindowsAppSDK#6620, microsoft/WindowsAppSDK#6786). + + **Behaviour change:** scaffolded apps are now **packaged** (single-project MSIX) rather than + unpackaged, so `dotnet run` launches with full package identity and requires Developer Mode. + No template produces the unpackaged shape any more — scaffold with `dotnet new reactor` and set + `None` yourself (see the packaging guide). + + `mur pack-local` no longer produces a templates nupkg and its `--framework-version` flag is + gone; the release workflow no longer packs or publishes the package. + ### Fixed ### Security diff --git a/Directory.Build.props b/Directory.Build.props index 0d96b4097..294cb62f9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,12 +40,8 @@ diff --git a/README.md b/README.md index 66fda136a..cb4a588c6 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ dotnet run -p:Platform=x64 `bootstrap.ps1` packs `mur` as a `dotnet tool` global install (cross-shell PATH, no per-arch `$env:Path` edits), packs local framework snapshots into `local-nupkgs/`, installs the Windows App SDK `dotnet new` template pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, which provides `dotnet new reactor`), and installs the Reactor agent plugin under `~/.claude/plugins/reactor`. Apps created by the template reference the public `Microsoft.UI.Reactor` package from NuGet.org by default; pass `--reactor-version 0.0.0-local` when you intentionally want a scaffolded app to consume the local source-built package instead. To test an unpublished build of the template pack, run `./bootstrap.ps1 -WinAppSdkTemplatesSource `. The optional `Microsoft.UI.Reactor.Advanced` and `Microsoft.UI.Reactor.Devtools` sibling packages are version-matched to the framework package when published. Re-run `bootstrap.ps1` (or `mur upgrade` for a lighter refresh) after `git pull` when you want updated CLI/plugin bits. Verify a working developer install with `mur doctor`. -> **Legacy template.** This repo still builds and publishes `Microsoft.UI.Reactor.ProjectTemplates`, which provides the older **unpackaged** `dotnet new reactorapp` template. `bootstrap.ps1` no longer installs it — run `dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg` if you specifically need that shape. +> **Scaffolding.** Reactor's `dotnet new` templates ship in the official Windows App SDK template pack — `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, then `dotnet new reactor`. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` package that used to provide `dotnet new reactorapp` has been removed; its published versions are deprecated on NuGet.org. On networks where the public npm or NuGet registries are unreachable, bootstrap detects a recognised package mirror already configured in the user's `~/.npmrc` and NuGet.Config, verifies unauthenticated package access, and uses it only for the bootstrap process — including the optional Visual Studio extension build. Contributors on an unrestricted network keep the public defaults, unchanged. Any mirror can be selected explicitly with `-NpmRegistry ` and `-NuGetConfig `; credentials remain in user configuration and are never written to the repository. Package feed URLs must use HTTPS (except loopback development feeds) and cannot embed credentials, query strings, or fragments. The npm mirror must permit direct package downloads because the Copilot SDK's MSBuild download task cannot forward npm credentials. diff --git a/Reactor.slnx b/Reactor.slnx index 147f15085..8d7225f04 100644 --- a/Reactor.slnx +++ b/Reactor.slnx @@ -494,9 +494,6 @@ - - - diff --git a/bootstrap.ps1 b/bootstrap.ps1 index ab56bcb71..89618d73e 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Builds the `mur` CLI, installs it as a dotnet global tool, packs the - framework + ProjectTemplates into local-nupkgs/, installs the Windows + framework packages into local-nupkgs/, installs the Windows App SDK `dotnet new` template pack (which ships `dotnet new reactor`), and (optionally) drops the Claude Code plugin under ~/.claude/plugins/reactor. @@ -302,8 +302,7 @@ function Get-VsExtensionSkipReason { # The repo defaults WindowsAppSDKSelfContained=false (see # Directory.Build.props) so samples and perf benches share a single # machine-wide Microsoft.WindowsAppRuntime install rather than bundling a -# copy of the runtime into every build output. The legacy in-repo template -# (tools/Templates/templates/WinUIApp-CSharp) and the AOT-publish trim +# copy of the runtime into every build output. The AOT-publish trim # proofs (tests/aot_trim_proof/*) keep =true explicitly so their build # output stays a standalone deployable. # @@ -622,20 +621,16 @@ if ($SkipMurInstall) { } # --------------------------------------------------------------------------- -# 4. Pack the in-source framework + templates via the freshly-installed mur +# 4. Pack the in-source framework packages via the freshly-installed mur # --------------------------------------------------------------------------- -Write-Step 'Packing local Microsoft.UI.Reactor + ProjectTemplates (`mur pack-local`)' +Write-Step 'Packing local Microsoft.UI.Reactor packages (`mur pack-local`)' # Use the freshly-installed `mur` if available; otherwise call the source # project directly (works for -SkipMurInstall too). # -# `--framework-version latest` stamps the newest *published* Microsoft.UI.Reactor -# into the legacy `reactorapp` template's , so the -# ProjectTemplates nupkg this produces tracks the current release automatically -# instead of a hand-maintained default. (Bootstrap no longer installs that -# template — step 5 installs the Windows App SDK pack instead — but the nupkg is -# still built here and published from the release workflow.) Best-effort: if -# NuGet is unreachable it falls back to the template's built-in default. +# Produces the source-built 0.0.0-local framework, Advanced and Devtools nupkgs +# so recipes, samples and scaffolded apps in this clone can resolve the working +# tree instead of the published package. $packLocalExit = 0 Invoke-ReactorWithRestoreEnvironment ` -NuGetConfig $effectiveNuGetConfig ` @@ -645,7 +640,7 @@ Invoke-ReactorWithRestoreEnvironment ` $murResolved = Get-Command mur -ErrorAction SilentlyContinue if ($murResolved) { Write-Dbg "Using installed mur at $($murResolved.Source)" - & mur pack-local --framework-version latest + & mur pack-local } else { Write-Dbg "mur not on PATH; falling back to 'dotnet run' against Reactor.Cli source" $murRestoreArgs = Get-ReactorRestoreArguments ` @@ -658,7 +653,7 @@ Invoke-ReactorWithRestoreEnvironment ` "-p:Platform=$hostArch" ` --nologo ` @murRestoreArgs ` - -- pack-local --framework-version latest + -- pack-local } } if ($packLocalExit -ne 0) { Fail 'mur pack-local failed' } @@ -673,13 +668,12 @@ if ($packLocalExit -ne 0) { Fail 'mur pack-local failed' } # (single-project MSIX) — so `dotnet run` launches them with package identity, # equivalent to F5 in Visual Studio. # -# This repo still builds and publishes its own legacy -# `Microsoft.UI.Reactor.ProjectTemplates` pack (`dotnet new reactorapp`, -# unpackaged) from tools/Templates/ — `mur pack-local` above just packed it -# into local-nupkgs/ — but bootstrap deliberately no longer *installs* it, so a -# fresh clone gets the officially supported templates by default. To opt back -# into the legacy unpackaged shape, install it by hand: -# dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +# This repo used to ship its own `Microsoft.UI.Reactor.ProjectTemplates` pack +# (`dotnet new reactorapp`, unpackaged). It was removed once the Windows App SDK +# pack shipped the Reactor templates; the published versions are deprecated on +# NuGet.org with a pointer here. For an unpackaged app, scaffold with +# `dotnet new reactor` and set `None` +# (see docs/guide/packaging.md). if ($SkipTemplates) { Write-Host '' Write-Host ' Skipping `dotnet new` template install (per -SkipTemplates).' -ForegroundColor Yellow diff --git a/build/pipelines/templates/reactor-build-steps.yml b/build/pipelines/templates/reactor-build-steps.yml index 65a6fd890..6ec992fc9 100644 --- a/build/pipelines/templates/reactor-build-steps.yml +++ b/build/pipelines/templates/reactor-build-steps.yml @@ -357,14 +357,6 @@ steps: -o "${{ parameters.outputDirectory }}\nupkg" displayName: 'Pack Devtools NuGet' - - pwsh: | - dotnet pack tools/Templates/Microsoft.UI.Reactor.Templates.csproj ` - --no-build --configuration ${{ parameters.configuration }} ` - -p:Version=$(PackageVersion) ` - -p:Platform=AnyCPU ` - -o "${{ parameters.outputDirectory }}\nupkg" - displayName: 'Pack Templates' - # ── NuGet.org Microsoft-compliance pre-flight (fail BEFORE the gated publish). ── # nuget.org enforces https://aka.ms/Microsoft-NuGet-Compliance on every # Microsoft-signed package: a missing ProjectUrl (or other required field) is @@ -379,7 +371,7 @@ steps: # never drift from what nuget.org actually enforces — the tool embeds the same # default rule set the server uses (required ProjectUrl + license, authors must # be "Microsoft", allowed copyright notices, etc.) and emits the identical - # "missing required ProjectUrl" message we saw reject ProjectTemplates at push + # "missing required ProjectUrl" message that has rejected a package at push # time. It is a net472 console exe shipped INSIDE the package's tools/ folder # (NOT a dotnet tool — `dotnet tool install` rejects it), so we restore the # package from the INTERNAL feed (it has been seeded there; the sealed build diff --git a/docs/_pipeline/templates/index.md.dt b/docs/_pipeline/templates/index.md.dt index 711ea1243..daec554e3 100644 --- a/docs/_pipeline/templates/index.md.dt +++ b/docs/_pipeline/templates/index.md.dt @@ -187,7 +187,7 @@ uniform Summary / Parameters / Returns / Discussion / Examples / See Also page. ## Minimal Project Setup -The fastest path is [`dotnet new reactorapp`](getting-started.md), which +The fastest path is [`dotnet new reactor`](getting-started.md), which scaffolds all of this for you. To wire it up by hand instead, create a console project and edit the `.csproj`: diff --git a/docs/contributing/release-runbook.md b/docs/contributing/release-runbook.md index 9852e7366..332d428ca 100644 --- a/docs/contributing/release-runbook.md +++ b/docs/contributing/release-runbook.md @@ -65,7 +65,6 @@ Also check NuGet.org for already-published packages: - `Microsoft.UI.Reactor` - `Microsoft.UI.Reactor.Advanced` - `Microsoft.UI.Reactor.Devtools` -- `Microsoft.UI.Reactor.ProjectTemplates` ## Prepare the release PR @@ -90,19 +89,15 @@ per-file bump**: - **Guide docs** — `docs/_pipeline/templates/*.md.dt` reference the version through the `{{reactorVersion}}` token, which `mur docs compile` substitutes from this property. -- **Template fallback** — `tools/Templates/Microsoft.UI.Reactor.Templates.csproj` derives its - `MicrosoftUIReactorVersion` fallback default from `$(ReactorPublicVersion)`. - **README** — is deliberately version-agnostic (it names no version and links to NuGet / Releases), so it needs no edit at all and `mur docs compile` never touches it. -The template's framework reference is *also* stamped automatically for the published package: -the release workflow's *Pack Templates* step passes `-p:MicrosoftUIReactorVersion=` (guarded by `TemplateMetadataTests`), so the published `ProjectTemplates` package -always references the framework version shipped in the same run. `bootstrap.ps1` runs `mur -pack-local --framework-version latest`, which resolves the newest published package from NuGet -for local scaffolds. The `$(ReactorPublicVersion)`-derived value is therefore only a *fallback* -(a bare `mur pack-local`, or when the `latest` lookup can't reach NuGet) — but keeping it -current is free now, since you bump the one property anyway. +Project scaffolding is **not** released from this repo. The `dotnet new reactor` templates ship +in the Windows App SDK template pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), versioned +and published by the WindowsAppSDK repo. When a release changes the framework version those +templates should reference, that bump happens there — see +[microsoft/WindowsAppSDK#6786](https://github.com/microsoft/WindowsAppSDK/pull/6786) for the shape +of that change. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` package was removed. Two guards keep the bump honest so it can't silently go stale: @@ -125,39 +120,31 @@ mur docs compile --skip-screenshots --skip-diagrams ## Validate the release PR -Run the focused template tests: +Run the focused version-consistency tests: ```powershell dotnet test tests/Reactor.Tests/Reactor.Tests.csproj ` -p:Platform=x64 ` - --filter FullyQualifiedName~TemplateMetadataTests + --filter FullyQualifiedName~VersionSingleSourceTests ``` -Pack the template locally and inspect the generated default. Pass -`-p:MicrosoftUIReactorVersion=$version` to mirror what the release workflow stamps, so the -generated app references the version being released: +Scaffolding is validated against the **published** Windows App SDK template pack rather than a +locally packed one. After the tag is published and the framework package is live on NuGet.org, +confirm a scaffold picks it up: ```powershell -dotnet pack tools/Templates/Microsoft.UI.Reactor.Templates.csproj ` - --configuration Release ` - -p:Version=0.0.0-local ` - -p:MicrosoftUIReactorVersion=$version ` - -p:Platform=AnyCPU ` - -o local-nupkgs -``` - -Create a throwaway app from the packed **legacy** template (`Microsoft.UI.Reactor.ProjectTemplates`, `dotnet new reactorapp`) and verify its `.csproj` references the chosen public version. This package is still published even though `bootstrap.ps1` no longer installs it — the supported scaffolding path is now `dotnet new reactor` from the Windows App SDK template pack, which is versioned and released by the WindowsAppSDK repo, not here. Skip restore before the tag is published because the new package version will not exist on NuGet.org yet: - -```powershell -dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates -dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates $scratch = Join-Path $env:TEMP "reactor-template-smoke" Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue -dotnet new reactorapp -n ReactorTemplateSmoke -o $scratch --no-restore +dotnet new reactor -n ReactorTemplateSmoke -o $scratch --no-restore --reactor-version $version Select-String "$scratch\ReactorTemplateSmoke.csproj" -Pattern $version ``` +Note the templates' *default* Reactor version is owned by the WindowsAppSDK repo and only changes +when a PR there bumps it, so a fresh release is not reflected in the default until that lands — +`--reactor-version` is how you check the new version scaffolds and restores cleanly in the meantime. + Open the PR and wait for CI. Do not tag until the release PR is merged. ## Tag the merged release commit diff --git a/docs/guide/README.md b/docs/guide/README.md index db51faf5a..a58d7b578 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -213,7 +213,7 @@ uniform Summary / Parameters / Returns / Discussion / Examples / See Also page. ## Minimal Project Setup -The fastest path is [`dotnet new reactorapp`](getting-started.md), which +The fastest path is [`dotnet new reactor`](getting-started.md), which scaffolds all of this for you. To wire it up by hand instead, create a console project and edit the `.csproj`: diff --git a/docs/guide/index.md b/docs/guide/index.md index db51faf5a..a58d7b578 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -213,7 +213,7 @@ uniform Summary / Parameters / Returns / Discussion / Examples / See Also page. ## Minimal Project Setup -The fastest path is [`dotnet new reactorapp`](getting-started.md), which +The fastest path is [`dotnet new reactor`](getting-started.md), which scaffolds all of this for you. To wire it up by hand instead, create a console project and edit the `.csproj`: diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 9858e941c..e58dd5d0b 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -83,7 +83,6 @@ Three things to notice: |---|---|---|---|---| | `Microsoft.UI.Reactor..nupkg` | NuGet, MSIL only | No | Not yet (tracked separately) | Framework | | `Microsoft.UI.Reactor..snupkg` | Symbols | No | n/a | | -| `Microsoft.UI.Reactor.Templates.*.nupkg` | NuGet | No | Not yet | `dotnet new` templates | | `reactor-skill-kit-.zip` | Zip | No (mur.exe is framework-dependent .NET 10) | Not yet | Contains `bin/{x64,arm64}/mur.exe`, `install-skill-kit.ps1` | No MSI, no MSIX, no Appx, no Authenticode-signed bundle. **Codesigning of NuGet and `mur.exe` is a known compliance gap** — tracked under the BinSkim/SDL stream, not in this threat model. diff --git a/plugins/reactor/skills/reactor-getting-started/SKILL.md b/plugins/reactor/skills/reactor-getting-started/SKILL.md index 08938cbd2..84d14bff0 100644 --- a/plugins/reactor/skills/reactor-getting-started/SKILL.md +++ b/plugins/reactor/skills/reactor-getting-started/SKILL.md @@ -65,8 +65,7 @@ produced.** Reactor works in both shapes: - **Unpackaged** — `None`, no `Package.appxmanifest`. `None` wins over `EnableMsixTooling`, so a project carrying both still builds unpackaged. Runs - from any folder, no MSIX registration. This is what the legacy `dotnet new reactorapp` template - produces. + from any folder, no MSIX registration. Scaffold with `dotnet new reactor` and set this property. - **Packaged** — has a `Package.appxmanifest`. Either omit `WindowsPackageType` entirely (the default produces a packaged app) or set it to `MSIX`; pair it with `EnableMsixTooling=true` for the single-project MSIX tooling. Launches with package identity, which is what identity-gated diff --git a/src/Reactor.Advanced/README.md b/src/Reactor.Advanced/README.md index 21d30188a..bc5f38cbf 100644 --- a/src/Reactor.Advanced/README.md +++ b/src/Reactor.Advanced/README.md @@ -83,7 +83,6 @@ When `radius` changes, the new `redrawKey` tells Reactor to invalidate the canva - [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) — the core declarative WinUI 3 framework (required). - [`Microsoft.UI.Reactor.Devtools`](https://www.nuget.org/packages/Microsoft.UI.Reactor.Devtools) — optional developer-loop devtools host. -- [`Microsoft.UI.Reactor.ProjectTemplates`](https://www.nuget.org/packages/Microsoft.UI.Reactor.ProjectTemplates) — `dotnet new` templates. ## Feedback & Contributing diff --git a/src/Reactor.Cli/Doctor/DoctorCommand.cs b/src/Reactor.Cli/Doctor/DoctorCommand.cs index 4ed05e338..dd329de07 100644 --- a/src/Reactor.Cli/Doctor/DoctorCommand.cs +++ b/src/Reactor.Cli/Doctor/DoctorCommand.cs @@ -16,13 +16,10 @@ // 4. local-nupkgs/Microsoft.UI.Reactor..nupkg present (framework) // 5. local-nupkgs/Microsoft.UI.Reactor.Advanced..nupkg present // (warn-only — opt-in Win2D canvas package) -// 6. local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates..nupkg present -// (warn-only — the legacy `dotnet new reactorapp` pack is still built and -// published, but nothing installs it automatically any more) -// 7. The Windows App SDK `dotnet new` template pack is registered, which is +// 6. The Windows App SDK `dotnet new` template pack is registered, which is // what provides `dotnet new reactor` (always runs — does not depend on // the repo checkout being found) -// 8. Claude plugin at ~/.claude/plugins/reactor (informational only; not +// 7. Claude plugin at ~/.claude/plugins/reactor (informational only; not // every developer uses Claude Code) using System.Diagnostics; @@ -102,7 +99,6 @@ public static int Run(string[] args) // DefaultLocalVersion is the literal "0.0.0-local" so this is purely defensive. var advancedFileName = $"Microsoft.UI.Reactor.Advanced.{PackLocalCommand.DefaultLocalVersion}.nupkg"; var advancedNupkg = Path.Combine(feed, Path.GetFileName(advancedFileName)); - var templateNupkg = Path.Combine(feed, $"Microsoft.UI.Reactor.ProjectTemplates.{PackLocalCommand.DefaultLocalVersion}.nupkg"); if (!File.Exists(frameworkNupkg)) { @@ -128,20 +124,6 @@ public static int Run(string[] args) { Pass("local Advanced nupkg", $"{Path.GetFileName(advancedNupkg)} ({FormatAge(File.GetLastWriteTimeUtc(advancedNupkg))})"); } - - // The legacy `dotnet new reactorapp` pack. `mur pack-local` still - // produces it and the release workflow still publishes it, but - // bootstrap no longer installs it — so a missing nupkg here means an - // incomplete pack-local, not a broken scaffolding story. Warn, don't fail. - if (!File.Exists(templateNupkg)) - { - Warn("local template nupkg", $"missing {templateNupkg}. Run `mur pack-local` if you need the legacy `dotnet new reactorapp` package."); - warnings++; - } - else - { - Pass("local template nupkg", $"{Path.GetFileName(templateNupkg)}"); - } } // 4. `dotnet new reactor` templates (Windows App SDK template pack). diff --git a/src/Reactor.Cli/Pack/CleanLocalCommand.cs b/src/Reactor.Cli/Pack/CleanLocalCommand.cs index f95a7e053..24b48a3c1 100644 --- a/src/Reactor.Cli/Pack/CleanLocalCommand.cs +++ b/src/Reactor.Cli/Pack/CleanLocalCommand.cs @@ -12,7 +12,11 @@ namespace Microsoft.UI.Reactor.Cli.Pack; public static class CleanLocalCommand { // Package IDs that `pack-local` produces — lowercase to match the NuGet - // global-packages folder convention. + // global-packages folder convention. `projecttemplates` is retained even + // though the package was removed from this repo: dev machines bootstrapped + // before the removal still have a stale nupkg in local-nupkgs/ and an + // extracted copy in the global cache, and cleaning those up is exactly what + // this command is for. internal static readonly string[] PackageIds = [ "microsoft.ui.reactor", @@ -21,7 +25,9 @@ public static class CleanLocalCommand "microsoft.ui.reactor.projecttemplates", ]; - // Template package ID used by `dotnet new install`. + // Legacy `dotnet new` template package, removed from this repo in favour of + // the Windows App SDK pack (`dotnet new reactor`). Kept here so the command + // can still unregister a stale install left behind on a dev machine. internal const string TemplatePackageId = "Microsoft.UI.Reactor.ProjectTemplates"; public static int Run(string[] args) @@ -93,7 +99,8 @@ public static int Run(string[] args) // 3. Clear the NuGet HTTP cache so stale metadata doesn't linger. RunDotnet(repoRoot, "nuget", "locals", "http-cache", "--clear"); - // 4. Uninstall project templates (non-fatal). + // 4. Unregister the legacy `dotnet new reactorapp` template if a stale + // install is still present from before it was removed (non-fatal). UninstallTemplates(repoRoot); Console.WriteLine(); diff --git a/src/Reactor.Cli/Pack/PackLocalCommand.cs b/src/Reactor.Cli/Pack/PackLocalCommand.cs index 3946acae3..046aabe9a 100644 --- a/src/Reactor.Cli/Pack/PackLocalCommand.cs +++ b/src/Reactor.Cli/Pack/PackLocalCommand.cs @@ -16,13 +16,6 @@ // --version package version stamped on the produced nupkgs // (default 0.0.0-local). // --configuration build configuration (default Debug). -// --framework-version version the packed `reactorapp` template tells -// generated apps to reference. Accepts an explicit -// version, or `latest` to resolve the newest published -// Microsoft.UI.Reactor from NuGet. Omitted → the -// template's csproj default. `bootstrap.ps1` passes -// `latest`, so a fresh clone scaffolds against the -// current release without a manual version bump. using System.Diagnostics; using System.Net.Http; @@ -38,7 +31,6 @@ public static int Run(string[] args) { var version = ParseFlag(args, "--version") ?? DefaultLocalVersion; var configuration = ParseFlag(args, "--configuration") ?? "Debug"; - var frameworkVersionArg = ParseFlag(args, "--framework-version"); var repoRoot = FindRepoRoot(); if (repoRoot is null) @@ -56,8 +48,6 @@ public static int Run(string[] args) // when `mur pack-local` reports success but the consumer keeps resolving stale. foreach (var stale in Directory.EnumerateFiles(feed, $"Microsoft.UI.Reactor.{version}.*nupkg")) DeleteStaleNupkg(stale); - foreach (var stale in Directory.EnumerateFiles(feed, $"Microsoft.UI.Reactor.ProjectTemplates.{version}.*nupkg")) - DeleteStaleNupkg(stale); foreach (var stale in Directory.EnumerateFiles(feed, $"Microsoft.UI.Reactor.Advanced.{version}.*nupkg")) DeleteStaleNupkg(stale); foreach (var stale in Directory.EnumerateFiles(feed, $"Microsoft.UI.Reactor.Devtools.{version}.*nupkg")) @@ -101,28 +91,6 @@ public static int Run(string[] args) return rc; } - // 4. Project templates — Microsoft.UI.Reactor.ProjectTemplates..nupkg. - // Powers `dotnet new reactorapp -n MyApp` against this clone. Templates pack - // is AnyCPU (no arch needed). The framework version the generated app - // references is baked into template.json at pack time from - // MicrosoftUIReactorVersion: - // • no --framework-version → the csproj default (a real *public* package), - // so a scaffold restores from NuGet.org and builds standalone. - // • --framework-version → stamp . `bootstrap.ps1` passes `latest`, - // which resolves the newest published package so the local scaffold - // default tracks releases automatically instead of drifting behind them. - // Either way it's a public NuGet version; pass `--MSUIReactorVersion - // 0.0.0-local` to `dotnet new reactorapp` to instead consume the - // source-built framework packed into this feed. - var templateFrameworkVersion = ResolveTemplateFrameworkVersion(frameworkVersionArg); - Console.WriteLine($"Packing Microsoft.UI.Reactor.ProjectTemplates {version} → {feed}"); - rc = RunPack(repoRoot, Path.Combine("tools", "Templates", "Microsoft.UI.Reactor.Templates.csproj"), configuration, version, feed, arch: null, frameworkVersion: templateFrameworkVersion); - if (rc != 0) - { - Console.Error.WriteLine("templates pack failed."); - return rc; - } - // Bust NuGet's HTTP cache for our local source so the new build is picked up // immediately on the next restore. Failure here is non-fatal but surfaced as // a warning — stale caches are a common "why doesn't my change take effect" @@ -168,7 +136,6 @@ public static int Run(string[] args) "microsoft.ui.reactor", "microsoft.ui.reactor.advanced", "microsoft.ui.reactor.devtools", - "microsoft.ui.reactor.projecttemplates", }) { var cached = Path.Combine(globalPackages, packageId, version); @@ -187,8 +154,6 @@ public static int Run(string[] args) } } - var templatesNupkg = Path.Combine(feed, $"Microsoft.UI.Reactor.ProjectTemplates.{version}.nupkg"); - Console.WriteLine(); Console.WriteLine($"Done. Apps in this repo can now reference:"); Console.WriteLine($" #:package Microsoft.UI.Reactor@{version}"); @@ -197,10 +162,9 @@ public static int Run(string[] args) Console.WriteLine($" "); Console.WriteLine($" "); Console.WriteLine(); - Console.WriteLine($"To use `dotnet new reactorapp` against this feed:"); - Console.WriteLine($" dotnet new install \"{templatesNupkg}\""); - Console.WriteLine($" # then, from anywhere inside this clone (so nuget.config applies):"); - Console.WriteLine($" dotnet new reactorapp -n MyApp"); + Console.WriteLine($"To scaffold an app against this feed:"); + Console.WriteLine($" dotnet new reactor -n MyApp --reactor-version {version}"); + Console.WriteLine($" # run from inside this clone so nuget.config applies."); Console.WriteLine($"Outside the clone, copy nuget.config to your project parent or add the absolute"); Console.WriteLine($"path '{feed}' as a NuGet source on your machine."); return 0; @@ -266,7 +230,7 @@ void AddPath(string? path) return paths; } - static int RunPack(string repoRoot, string projectRelative, string configuration, string version, string feed, string? arch, string? frameworkVersion = null) + static int RunPack(string repoRoot, string projectRelative, string configuration, string version, string feed, string? arch) { var psi = new ProcessStartInfo("dotnet") { @@ -279,10 +243,6 @@ static int RunPack(string repoRoot, string projectRelative, string configuration psi.ArgumentList.Add("-v:m"); psi.ArgumentList.Add($"-c:{configuration}"); psi.ArgumentList.Add($"-p:Version={version}"); - // Stamp the framework version the generated template references (baked into - // template.json by the templates csproj's BeforePack target). Supplied only - // for the templates pack; null leaves the csproj default in place. - if (frameworkVersion is not null) psi.ArgumentList.Add($"-p:MicrosoftUIReactorVersion={frameworkVersion}"); psi.ArgumentList.Add($"-o:{feed}"); if (arch is not null) psi.ArgumentList.Add($"-p:Platform={arch}"); @@ -318,59 +278,6 @@ static void DeleteStaleNupkg(string path) return null; } - // Resolve the framework version to bake into the packed template's - // , from the --framework-version flag value: - // • null/empty → null (leave the csproj default in place; today's behavior). - // • "latest" → newest published Microsoft.UI.Reactor on NuGet.org, or null - // (warn + fall back to the csproj default) when it can't be - // resolved. Best-effort so `bootstrap.ps1` on a flaky network - // still produces a usable feed. - // • otherwise → the literal version supplied. - internal static string? ResolveTemplateFrameworkVersion(string? frameworkVersionArg) - { - if (string.IsNullOrWhiteSpace(frameworkVersionArg)) - return null; - - var trimmed = frameworkVersionArg.Trim(); - if (!trimmed.Equals("latest", StringComparison.OrdinalIgnoreCase)) - return trimmed; - - var latest = TryResolveLatestPublishedFrameworkVersion(); - if (latest is null) - { - Console.Error.WriteLine( - "warning: could not resolve the latest published Microsoft.UI.Reactor version from NuGet; " + - "scaffolded apps will reference the template's built-in default version. Re-run with network " + - "access, or pass an explicit --framework-version ."); - return null; - } - - Console.WriteLine($"Latest published Microsoft.UI.Reactor: {latest} (stamped into the template)"); - return latest; - } - - // NuGet flat-container index for the published framework package. Lists every - // published version (including prereleases) as a JSON string array. - const string FrameworkFlatContainerIndexUrl = - "https://api.nuget.org/v3-flatcontainer/microsoft.ui.reactor/index.json"; - - static string? TryResolveLatestPublishedFrameworkVersion() - { - try - { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; - var json = http.GetStringAsync(FrameworkFlatContainerIndexUrl).GetAwaiter().GetResult(); - return SelectLatestVersion(ParseFlatContainerVersions(json)); - } - catch (Exception ex) - { - Console.Error.WriteLine( - $"warning: querying NuGet for the latest Microsoft.UI.Reactor version failed " + - $"({ex.GetType().Name}: {ex.Message})."); - return null; - } - } - // Extract the "versions" string array from a NuGet flat-container index.json. // Returns an empty list for malformed / oddly-shaped payloads (best-effort). internal static IReadOnlyList ParseFlatContainerVersions(string json) diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index 4e5e10fbe..fc94ffa96 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -1,7 +1,7 @@ // `mur upgrade` — refresh a Reactor developer install after `git pull`. // // Re-runs the source-side steps of bootstrap.ps1: -// 1. Re-pack the framework + ProjectTemplates into local-nupkgs/ +// 1. Re-pack the framework packages into local-nupkgs/ // (delegates to `mur pack-local`). // 2. Make sure the Windows App SDK `dotnet new` template pack (which ships // `dotnet new reactor`) is installed — self-healing for a checkout that @@ -37,8 +37,8 @@ public static int Run(string[] args) return 1; } - // 1. Re-pack framework + templates. - Console.WriteLine("==> Repacking Microsoft.UI.Reactor + ProjectTemplates"); + // 1. Re-pack framework packages. + Console.WriteLine("==> Repacking Microsoft.UI.Reactor packages"); var rc = PackLocalCommand.Run(Array.Empty()); if (rc != 0) { @@ -51,11 +51,6 @@ public static int Run(string[] args) // checkout, so `git pull` never invalidates them — this is a self-healing // install-if-missing, not a reinstall. Best-effort: a developer who // scaffolds by hand shouldn't have `mur upgrade` fail on a NuGet hiccup. - // - // The legacy in-repo `Microsoft.UI.Reactor.ProjectTemplates` pack - // (`dotnet new reactorapp`) is still repacked by step 1, but is - // deliberately not installed. Install it manually if you want it: - // dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg Console.WriteLine(); var templateSource = ParseFlag(args, "--templates-source"); var templateVersion = ParseFlag(args, "--templates-version"); diff --git a/src/Reactor.Devtools/README.md b/src/Reactor.Devtools/README.md index fdf574840..eb12eb79d 100644 --- a/src/Reactor.Devtools/README.md +++ b/src/Reactor.Devtools/README.md @@ -71,7 +71,6 @@ The devtools surface is **developer-loop only** and ships with hard gates: - [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) — the core declarative WinUI 3 framework (required). - [`Microsoft.UI.Reactor.Advanced`](https://www.nuget.org/packages/Microsoft.UI.Reactor.Advanced) — optional Win2D/graphics components. -- [`Microsoft.UI.Reactor.ProjectTemplates`](https://www.nuget.org/packages/Microsoft.UI.Reactor.ProjectTemplates) — `dotnet new` templates. ## Feedback & Contributing diff --git a/src/Reactor/README.md b/src/Reactor/README.md index 42284b416..be926cb45 100644 --- a/src/Reactor/README.md +++ b/src/Reactor/README.md @@ -107,7 +107,6 @@ From here, the same model scales to real apps. The [getting started guide](https - [`Microsoft.UI.Reactor.Advanced`](https://www.nuget.org/packages/Microsoft.UI.Reactor.Advanced) — optional components with heavier native/graphics dependencies (Win2D canvas, charts). - [`Microsoft.UI.Reactor.Devtools`](https://www.nuget.org/packages/Microsoft.UI.Reactor.Devtools) — optional developer-loop devtools host (live tree inspection, hot reload, preview). -- [`Microsoft.UI.Reactor.ProjectTemplates`](https://www.nuget.org/packages/Microsoft.UI.Reactor.ProjectTemplates) — `dotnet new` templates for scaffolding Reactor apps. ## Feedback & Contributing diff --git a/tests/Reactor.IntegrationTests/Packaging/CreateTemplateTests.cs b/tests/Reactor.IntegrationTests/Packaging/CreateTemplateTests.cs deleted file mode 100644 index 9f229b402..000000000 --- a/tests/Reactor.IntegrationTests/Packaging/CreateTemplateTests.cs +++ /dev/null @@ -1,376 +0,0 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using Xunit; -using Xunit.Sdk; - -namespace Microsoft.UI.Reactor.IntegrationTests.Packaging; - -[Collection(LocalPackageFeedCollection.Name)] -public sealed class CreateTemplateTests : IDisposable -{ - private readonly TemplatePackageTestFixture _fixture; - private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), $"reactor-template-smoke-{Guid.NewGuid():N}"); - - public CreateTemplateTests(TemplatePackageTestFixture fixture) - { - _fixture = fixture; - Directory.CreateDirectory(_tempRoot); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void CreateAndRunReactorappTemplate(bool useProgramMain) - { - var scenarioName = useProgramMain ? "program-main" : "top-level"; - var appDir = CreateDirectory($"generated-app-{scenarioName}"); - var projectName = CreateProjectName(useProgramMain); - - CreateNuGetConfig(appDir, _fixture.PackageSourceDir, _fixture.NugetPackagesDir, _fixture.CommandEnvironment); - - RunHelpers.RunDotnet( - $"new reactorapp --debug:custom-hive \"{_fixture.TemplateHiveDir}\" --use-program-main {useProgramMain.ToString().ToLowerInvariant()} --name {projectName} --output \"{appDir}\" --force", - _fixture.RepoRoot, - _fixture.CommandEnvironment, - timeoutMs: 180_000); - - var projectPath = Path.Combine(appDir, $"{projectName}.csproj"); - Assert.True(File.Exists(projectPath), $"Expected generated project at '{projectPath}'."); - - RunHelpers.RunDotnet( - $"build -a {_fixture.RunArchitecture}", - appDir, - _fixture.CommandEnvironment, - timeoutMs: 300_000); - - RunDotnetRun( - appDir, - projectName, - _fixture.RunArchitecture, - configuration: null, - _fixture.CommandEnvironment, - timeoutMs: 120_000); - - RunDotnetRun( - appDir, - projectName, - _fixture.RunArchitecture, - configuration: "Release", - _fixture.CommandEnvironment, - timeoutMs: 120_000); - } - - public void Dispose() - { - try - { - if (Directory.Exists(_tempRoot)) - { - Directory.Delete(_tempRoot, recursive: true); - } - } - catch - { - // Best-effort cleanup for temporary smoke-test artifacts. - } - } - - private string CreateDirectory(string name) - { - var path = Path.Combine(_tempRoot, name); - Directory.CreateDirectory(path); - return path; - } - - private static void CreateNuGetConfig( - string workingDirectory, - string packageSourceDir, - string nugetPackagesDir, - IReadOnlyDictionary environmentVariables) - { - RunHelpers.RunDotnet( - $"new nugetconfig --output \"{workingDirectory}\" --force", - workingDirectory, - environmentVariables, - timeoutMs: 30_000); - - var configPath = Path.Combine(workingDirectory, "nuget.config"); - - RunHelpers.RunDotnet( - $"nuget config set globalPackagesFolder \"{nugetPackagesDir}\" --configfile \"{configPath}\"", - workingDirectory, - environmentVariables, - timeoutMs: 30_000); - - RunHelpers.RunDotnet( - $"nuget add source \"{packageSourceDir}\" --name reactor-local --configfile \"{configPath}\"", - workingDirectory, - environmentVariables, - timeoutMs: 30_000); - } - - private static string CreateProjectName(bool useProgramMain) - { - var prefix = useProgramMain ? "ReactorProgMain" : "ReactorTopLevel"; - return $"{prefix}{Guid.NewGuid():N}".Substring(0, 28); - } - - private static void RunDotnetRun( - string workingDirectory, - string projectName, - string architecture, - string? configuration, - IReadOnlyDictionary environmentVariables, - int timeoutMs) - { - var stdout = new StringBuilder(); - var stderr = new StringBuilder(); - var outputLock = new object(); - var sawChildProcess = false; - string? lastUiDetails = null; - - var configurationArg = configuration is null ? string.Empty : $" -c {configuration}"; - using var process = RunHelpers.CreateProcess("dotnet", $"run{configurationArg} -a {architecture}", workingDirectory, environmentVariables); - process.OutputDataReceived += (_, args) => - { - if (args.Data is null) - { - return; - } - - lock (outputLock) - { - stdout.AppendLine(args.Data); - } - }; - process.ErrorDataReceived += (_, args) => - { - if (args.Data is null) - { - return; - } - - lock (outputLock) - { - stderr.AppendLine(args.Data); - } - }; - - if (!process.Start()) - { - throw new XunitException("Failed to start 'dotnet run'."); - } - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - try - { - var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - if (process.HasExited) - { - break; - } - - var launchedProcess = Process.GetProcessesByName(projectName) - .FirstOrDefault(candidate => !candidate.HasExited); - if (launchedProcess != null) - { - sawChildProcess = true; - var uiState = UiaHelpers.FindUIALement(launchedProcess, "NameInput", out var uiDetails); - lastUiDetails = uiDetails; - launchedProcess.Dispose(); - - if (uiState == UiaHelpers.UIAFindResult.Found) - { - return; - } - - if (uiState == UiaHelpers.UIAFindResult.NotFound) - { - throw new XunitException( - $"Generated app showed Reactor's render-error fallback instead of the expected template UI.{Environment.NewLine}" + - $"Working directory: {workingDirectory}{Environment.NewLine}" + - $"UI Automation details: {uiDetails}{Environment.NewLine}" + - RunHelpers.FormatCommandOutput(stdout.ToString(), stderr.ToString())); - } - } - - Thread.Sleep(500); - } - - if (process.HasExited) - { - process.WaitForExit(); - throw new XunitException( - $"Command failed: dotnet run{configurationArg} -a {architecture}{Environment.NewLine}" + - $"Exit code: {process.ExitCode}{Environment.NewLine}" + - $"Working directory: {workingDirectory}{Environment.NewLine}" + - RunHelpers.FormatCommandOutput(stdout.ToString(), stderr.ToString())); - } - - throw new XunitException( - $"Timed out waiting for '{projectName}.exe' to start from 'dotnet run{configurationArg} -a {architecture}'. " + - $"Child process observed: {sawChildProcess}.{Environment.NewLine}" + - $"UI Automation details: {lastUiDetails ?? "None captured."}{Environment.NewLine}" + - $"Working directory: {workingDirectory}{Environment.NewLine}" + - RunHelpers.FormatCommandOutput(stdout.ToString(), stderr.ToString())); - } - finally - { - RunHelpers.TryKillProcessTree(process); - foreach (var launchedProcess in Process.GetProcessesByName(projectName)) - { - try - { - if (!launchedProcess.HasExited) - { - launchedProcess.Kill(entireProcessTree: true); - launchedProcess.WaitForExit(5_000); - } - } - catch - { - // Best-effort cleanup for unique smoke-test app names. - } - finally - { - launchedProcess.Dispose(); - } - } - } - } -} - -public sealed class TemplatePackageTestFixture : IDisposable -{ - private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), $"reactor-template-packages-{Guid.NewGuid():N}"); - - public TemplatePackageTestFixture() - { - Directory.CreateDirectory(_tempRoot); - - RepoRoot = FindRepoRoot(); - var packageSuffix = Guid.NewGuid().ToString("N")[..12]; - PackageVersion = $"0.0.0-template-smoke-{packageSuffix}"; - PackageSourceDir = CreateDirectory("packages"); - NugetPackagesDir = CreateDirectory("nuget-global-packages"); - var nugetHttpCacheDir = CreateDirectory("nuget-http-cache"); - var dotnetCliHomeDir = CreateDirectory("dotnet-home"); - TemplateHiveDir = CreateDirectory("template-hive"); - RunArchitecture = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; - CommandEnvironment = CreateCommandEnvironment(dotnetCliHomeDir, nugetHttpCacheDir); - - RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "src", "Reactor", "Reactor.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", - RepoRoot, - CommandEnvironment, - timeoutMs: 300_000); - // The template's Debug-only ItemGroup also references Microsoft.UI.Reactor.Devtools - // (so the devtools menu lights up in Debug + the VS embedded-preview extension works). - // Pack it here too so the post-scaffold `dotnet build` finds the matching version on - // the local feed instead of trying NuGet.org. - RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "src", "Reactor.Devtools", "Reactor.Devtools.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", - RepoRoot, - CommandEnvironment, - timeoutMs: 300_000); - // Track B (spec-062): Microsoft.UI.Reactor.Devtools now depends on - // Microsoft.UI.Reactor.Advanced (its docking devtools use Advanced internals), - // so the scaffolded app pulls Advanced transitively via its Debug ItemGroup. - // Pack it here too so the post-scaffold `dotnet build` resolves the matching - // smoke version from the local feed instead of falling back to NuGet.org - // (which caused an NU1605 downgrade of Microsoft.UI.Reactor). - RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "src", "Reactor.Advanced", "Reactor.Advanced.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", - RepoRoot, - CommandEnvironment, - timeoutMs: 300_000); - RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "tools", "Templates", "Microsoft.UI.Reactor.Templates.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion} -p:MicrosoftUIReactorVersion={PackageVersion} -p:Platform=AnyCPU", - RepoRoot, - CommandEnvironment, - timeoutMs: 180_000); - - _ = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor", PackageVersion); - _ = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor.Devtools", PackageVersion); - _ = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor.Advanced", PackageVersion); - var templatePackage = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor.ProjectTemplates", PackageVersion); - - RunHelpers.RunDotnet( - $"new install --debug:custom-hive \"{TemplateHiveDir}\" \"{templatePackage}\"", - RepoRoot, - CommandEnvironment, - timeoutMs: 120_000); - } - - private static string FindPackage(string packageSourceDir, string packageId, string version) - { - var packagePath = Path.Combine(packageSourceDir, $"{packageId}.{version}.nupkg"); - Assert.True(File.Exists(packagePath), $"Expected package '{packagePath}' to exist."); - return packagePath; - } - - private static Dictionary CreateCommandEnvironment(string dotnetCliHomeDir, string nugetHttpCacheDir) - { - return new(StringComparer.OrdinalIgnoreCase) - { - ["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "false", - ["DOTNET_CLI_HOME"] = dotnetCliHomeDir, - ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", - ["DOTNET_NOLOGO"] = "1", - ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", - ["NUGET_HTTP_CACHE_PATH"] = nugetHttpCacheDir, - }; - } - - public string RepoRoot { get; } - - public string PackageVersion { get; } - - public string PackageSourceDir { get; } - - public string NugetPackagesDir { get; } - - public string TemplateHiveDir { get; } - - public string RunArchitecture { get; } - - public IReadOnlyDictionary CommandEnvironment { get; } - - public void Dispose() - { - try - { - if (Directory.Exists(_tempRoot)) - { - Directory.Delete(_tempRoot, recursive: true); - } - } - catch - { - // Best-effort cleanup for shared package-setup artifacts. - } - } - - private string CreateDirectory(string name) - { - var path = Path.Combine(_tempRoot, name); - Directory.CreateDirectory(path); - return path; - } - - private static string FindRepoRoot() - { - var dir = AppContext.BaseDirectory; - while (dir != null && !File.Exists(Path.Combine(dir, "Reactor.slnx"))) - { - dir = Path.GetDirectoryName(dir); - } - - return dir ?? throw new DirectoryNotFoundException("Could not find repo root (Reactor.sln)."); - } -} diff --git a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedCollection.cs b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedCollection.cs index 78d5b2387..5a0965713 100644 --- a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedCollection.cs +++ b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedCollection.cs @@ -13,7 +13,7 @@ namespace Microsoft.UI.Reactor.IntegrationTests.Packaging; /// Sharing one fixture also means the ~2-minute pack happens once instead of per class. /// [CollectionDefinition(Name, DisableParallelization = true)] -public sealed class LocalPackageFeedCollection : ICollectionFixture +public sealed class LocalPackageFeedCollection : ICollectionFixture { public const string Name = "LocalPackageFeed"; } diff --git a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs new file mode 100644 index 000000000..8dcce698c --- /dev/null +++ b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs @@ -0,0 +1,122 @@ +// Shared fixture that packs the Reactor framework packages into a throwaway +// local NuGet feed, so consumer-facing integration tests can restore against a +// real .nupkg instead of a project reference. +// +// It previously also packed and installed the in-repo +// `Microsoft.UI.Reactor.ProjectTemplates` package and scaffolded from it. That +// package was removed in favour of the Windows App SDK `dotnet new reactor` +// templates, so the template-pack and template-hive plumbing is gone; what +// remains is the local package feed itself. + +using System.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.UI.Reactor.IntegrationTests.Packaging; + +public sealed class LocalPackageFeedFixture : IDisposable +{ + private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), $"reactor-local-feed-{Guid.NewGuid():N}"); + + public LocalPackageFeedFixture() + { + Directory.CreateDirectory(_tempRoot); + + RepoRoot = FindRepoRoot(); + var packageSuffix = Guid.NewGuid().ToString("N")[..12]; + PackageVersion = $"0.0.0-feed-smoke-{packageSuffix}"; + PackageSourceDir = CreateDirectory("packages"); + NugetPackagesDir = CreateDirectory("nuget-global-packages"); + var nugetHttpCacheDir = CreateDirectory("nuget-http-cache"); + var dotnetCliHomeDir = CreateDirectory("dotnet-home"); + RunArchitecture = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + CommandEnvironment = CreateCommandEnvironment(dotnetCliHomeDir, nugetHttpCacheDir); + + RunHelpers.RunDotnet( + $"pack \"{Path.Combine(RepoRoot, "src", "Reactor", "Reactor.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", + RepoRoot, + CommandEnvironment, + timeoutMs: 300_000); + // Devtools depends on Advanced (spec-062), and consumers can pull both + // transitively, so pack all three at the same version to keep a restore + // from falling through to NuGet.org and hitting an NU1605 downgrade. + RunHelpers.RunDotnet( + $"pack \"{Path.Combine(RepoRoot, "src", "Reactor.Devtools", "Reactor.Devtools.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", + RepoRoot, + CommandEnvironment, + timeoutMs: 300_000); + RunHelpers.RunDotnet( + $"pack \"{Path.Combine(RepoRoot, "src", "Reactor.Advanced", "Reactor.Advanced.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", + RepoRoot, + CommandEnvironment, + timeoutMs: 300_000); + + _ = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor", PackageVersion); + _ = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor.Devtools", PackageVersion); + _ = FindPackage(PackageSourceDir, "Microsoft.UI.Reactor.Advanced", PackageVersion); + } + + private static string FindPackage(string packageSourceDir, string packageId, string version) + { + var packagePath = Path.Combine(packageSourceDir, $"{packageId}.{version}.nupkg"); + Assert.True(File.Exists(packagePath), $"Expected package '{packagePath}' to exist."); + return packagePath; + } + + private static Dictionary CreateCommandEnvironment(string dotnetCliHomeDir, string nugetHttpCacheDir) + { + return new(StringComparer.OrdinalIgnoreCase) + { + ["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "false", + ["DOTNET_CLI_HOME"] = dotnetCliHomeDir, + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + ["NUGET_HTTP_CACHE_PATH"] = nugetHttpCacheDir, + }; + } + + public string RepoRoot { get; } + + public string PackageVersion { get; } + + public string PackageSourceDir { get; } + + public string NugetPackagesDir { get; } + + public string RunArchitecture { get; } + + public IReadOnlyDictionary CommandEnvironment { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + catch + { + // Best-effort cleanup for shared package-setup artifacts. + } + } + + private string CreateDirectory(string name) + { + var path = Path.Combine(_tempRoot, name); + Directory.CreateDirectory(path); + return path; + } + + private static string FindRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir != null && !File.Exists(Path.Combine(dir, "Reactor.slnx"))) + { + dir = Path.GetDirectoryName(dir); + } + + return dir ?? throw new DirectoryNotFoundException("Could not find repo root (Reactor.slnx)."); + } +} diff --git a/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs b/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs index bf2c12cd9..054b72d96 100644 --- a/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs +++ b/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs @@ -25,10 +25,10 @@ namespace Microsoft.UI.Reactor.IntegrationTests.Packaging; [Collection(LocalPackageFeedCollection.Name)] public sealed class SourceMapPackageConsumerTests : IDisposable { - private readonly TemplatePackageTestFixture _fixture; + private readonly LocalPackageFeedFixture _fixture; private readonly string _tempRoot = Path.Join(Path.GetTempPath(), $"reactor-sourcemap-pkg-{Guid.NewGuid():N}"); - public SourceMapPackageConsumerTests(TemplatePackageTestFixture fixture) + public SourceMapPackageConsumerTests(LocalPackageFeedFixture fixture) { _fixture = fixture; Directory.CreateDirectory(_tempRoot); diff --git a/tests/Reactor.Tests/PackLocalFrameworkVersionTests.cs b/tests/Reactor.Tests/PackLocalFrameworkVersionTests.cs index 24250e392..127013315 100644 --- a/tests/Reactor.Tests/PackLocalFrameworkVersionTests.cs +++ b/tests/Reactor.Tests/PackLocalFrameworkVersionTests.cs @@ -126,24 +126,4 @@ public void ParseFlatContainerVersions_end_to_end_picks_latest() Assert.Equal("0.1.0-preview.11", latest); } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void ResolveTemplateFrameworkVersion_returns_null_for_no_flag(string? arg) - { - // No --framework-version → leave the csproj default in place (no network). - Assert.Null(PackLocalCommand.ResolveTemplateFrameworkVersion(arg)); - } - - [Fact] - public void ResolveTemplateFrameworkVersion_passes_explicit_version_through() - { - // An explicit version is used verbatim and must not trigger a NuGet lookup. - Assert.Equal("0.1.0-preview.7", - PackLocalCommand.ResolveTemplateFrameworkVersion("0.1.0-preview.7")); - Assert.Equal("0.1.0-preview.7", - PackLocalCommand.ResolveTemplateFrameworkVersion(" 0.1.0-preview.7 ")); - } } diff --git a/tests/Reactor.Tests/TemplateMetadataTests.cs b/tests/Reactor.Tests/TemplateMetadataTests.cs deleted file mode 100644 index 5389c792d..000000000 --- a/tests/Reactor.Tests/TemplateMetadataTests.cs +++ /dev/null @@ -1,257 +0,0 @@ -// Repository-content validation for the legacy project-template metadata. -// -// These guard `tools/Templates/` — the in-repo `Microsoft.UI.Reactor.ProjectTemplates` -// pack that provides `dotnet new reactorapp`. That pack is still built by -// `mur pack-local` and published by the release workflow, but as of the move to -// the Windows App SDK template pack it is no longer installed by `bootstrap.ps1`. -// `dotnet new reactor` (packaged, from Microsoft.WindowsAppSDK.WinUI.CSharp.Templates) -// is the supported scaffolding path; see WinAppSdkTemplatesTests. -// -// The bug this file was originally added against: -// `tools/Templates/templates/WinUIApp-CSharp/.template.config/template.json` -// shipped with `identity` = "Micrsoft.UI.Reactor.CSharp" (missing the -// second 'o') from at least Phase 1 onward. The existing integration test -// `CreateTemplateTests` did not catch this because it installs the -// template into a per-test ephemeral hive via `--debug:custom-hive`, -// where the misspelled identity is unique (no duplicates), so -// `dotnet new reactorapp` resolves correctly inside the fresh hive. -// -// The typo only surfaces against the user's *real* template cache -// (~/.templateengine/dotnetcli//templatecache.json), where every -// harness run that does `dotnet new install ... --force` accumulates -// duplicate entries for the same misspelled identity. Eventually the -// `dotnet new reactorapp` short-name lookup finds more than one match -// and throws "Sequence contains more than one matching element" with -// exit code 70. The EC3 eval batch hit this 20/20 runs. -// -// What this test asserts: -// - The template's `identity` and `groupIdentity` use the canonical -// Microsoft.UI.Reactor brand namespace, not any spelling variant. -// - The file contains no `Micrsoft` substring anywhere (catches the -// same typo if it sneaks back in via copy-paste in a new symbol / -// description / etc.). -// -// What this test deliberately does NOT do: -// - Run `dotnet new install`. That path is covered by -// `tests/Reactor.IntegrationTests/Packaging/CreateTemplateTests.cs`. -// Content validation belongs in fast unit tests so a typo lights up -// in seconds rather than minutes. - -using System.Text.Json; -using Xunit; - -namespace Microsoft.UI.Reactor.Tests; - -public sealed class TemplateMetadataTests -{ - static readonly string TemplateJsonPath = Path.Combine( - "tools", "Templates", "templates", "WinUIApp-CSharp", ".template.config", "template.json"); - - [Fact] - public void Identity_is_canonical_brand_namespace() - { - var doc = LoadTemplateJson(); - var identity = doc.RootElement.GetProperty("identity").GetString(); - Assert.Equal("Microsoft.UI.Reactor.CSharp", identity); - } - - [Fact] - public void GroupIdentity_is_canonical_brand_namespace() - { - var doc = LoadTemplateJson(); - var groupIdentity = doc.RootElement.GetProperty("groupIdentity").GetString(); - Assert.Equal("Microsoft.UI.Reactor", groupIdentity); - } - - [Fact] - public void File_contains_no_brand_typos() - { - // Broad guard: catches any future typo of the same shape in any - // field of the file. The exact-match assertions above are the - // load-bearing checks; this is the belt-and-suspenders sweep. - var (path, text) = ReadTemplateJson(); - Assert.False( - text.Contains("Micrsoft", StringComparison.Ordinal), - $"'{path}' contains the typo 'Micrsoft' (missing the second 'o'). " + - $"Use 'Microsoft' everywhere — the identity/groupIdentity fields are load-bearing for `dotnet new`'s template-cache lookup."); - } - - [Fact] - public void ShortName_resolves_to_reactorapp() - { - // Anchors the public CLI command-name the agent docs (and the - // wordpuzzle smoke pattern) depend on. Changing it is a breaking - // change; this test surfaces an accidental rename. - var doc = LoadTemplateJson(); - var shortName = doc.RootElement.GetProperty("shortName").GetString(); - Assert.Equal("reactorapp", shortName); - } - - // ── Framework-version drift guard ────────────────────────────────────── - // - // The template bakes the framework version generated apps reference - // (`MicrosoftUIReactorVersion` → template.json `MSUIReactorVersion` - // defaultValue, via the csproj BeforePack target). That version used to be - // a hardcoded csproj default that had to be hand-bumped every release — and - // it silently drifted (stuck at preview.4 while the framework shipped - // through preview.11), so published templates generated apps referencing an - // ancient package (see issue #866). The fix makes the release workflow STAMP - // the version it's publishing onto the templates pack. This test fails the - // instant that automation is removed, so the drift can't silently return. - - [Fact] - public void ReleaseWorkflow_stamps_framework_version_into_templates_pack() - { - // The release "Pack Templates" step must pass -p:MicrosoftUIReactorVersion - // = the resolved release version, so the published ProjectTemplates - // package references the framework version published in the same run. - var (path, text) = ReadRepoFile(Path.Combine(".github", "workflows", "release.yml")); - var step = ExtractYamlStep(text, "Pack Templates"); - Assert.False(step is null, - $"'{path}' has no 'Pack Templates' step — the release template pack moved or was renamed."); - Assert.Contains("Microsoft.UI.Reactor.Templates.csproj", step, StringComparison.Ordinal); - Assert.True( - step!.Contains("-p:MicrosoftUIReactorVersion=${{ steps.version.outputs.version }}", StringComparison.Ordinal), - $"The 'Pack Templates' step in '{path}' must pass " + - "'-p:MicrosoftUIReactorVersion=${{ steps.version.outputs.version }}' so the published " + - "template references the framework version shipped in the same release run. Without it, " + - "the baked reference falls back to the csproj default and drifts behind the framework (issue #866)."); - } - - [Fact] - public void Bootstrap_packs_templates_with_latest_framework_version() - { - // The local side of the same fix: bootstrap.ps1 must pack the legacy - // templates with `--framework-version latest` so the ProjectTemplates - // nupkg tracks the newest published package instead of a hand-maintained - // csproj default. Fails the instant that wiring is dropped from either - // invocation path (installed `mur` or the `dotnet run` fallback). - var (path, text) = ReadRepoFile("bootstrap.ps1"); - var normalized = text.Replace("\r\n", "\n"); - var matches = global::System.Text.RegularExpressions.Regex.Matches( - normalized, @"pack-local\s+--framework-version\s+latest"); - Assert.True( - matches.Count >= 2, - $"'{path}' must invoke `mur pack-local --framework-version latest` on both the installed-`mur` " + - $"and `dotnet run` fallback paths so the packed templates track the latest published framework " + - $"(found {matches.Count}, expected >= 2). Dropping it re-introduces the drift fixed for issue #866."); - } - - // ── Template-pack migration guard ────────────────────────────────────── - // - // Reactor's app templates moved into the Windows App SDK `dotnet new` pack - // (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, short name `reactor`). - // bootstrap.ps1 installs *that* pack and deliberately no longer installs the - // in-repo `Microsoft.UI.Reactor.ProjectTemplates` one. These two tests pin - // both halves of that contract — the regression they guard is silent - // (a bootstrap that quietly re-registers `reactorapp` would hand new - // developers the unpackaged template the docs no longer describe). - - [Fact] - public void Bootstrap_installs_the_windows_app_sdk_template_pack() - { - var (path, text) = ReadRepoFile("bootstrap.ps1"); - Assert.Contains("Microsoft.WindowsAppSDK.WinUI.CSharp.Templates", text, StringComparison.Ordinal); - Assert.True( - global::System.Text.RegularExpressions.Regex.IsMatch( - text.Replace("\r\n", "\n"), @"templates',\s*'install'"), - $"'{path}' must install the Reactor templates via `mur templates install`, which resolves the " + - "newest published version of the Windows App SDK template pack. `dotnet new install` has no " + - "--prerelease switch and resolves stable-only, so installing the bare package id fails while " + - "the pack is prerelease-only."); - } - - [Fact] - public void Bootstrap_does_not_install_the_legacy_reactorapp_template() - { - // `mur pack-local` still *builds* Microsoft.UI.Reactor.ProjectTemplates - // and the release workflow still publishes it — but nothing in bootstrap - // may hand it to `dotnet new install`, or a fresh clone silently gets the - // legacy unpackaged `reactorapp` template back. - var (path, text) = ReadRepoFile("bootstrap.ps1"); - var normalized = text.Replace("\r\n", "\n"); - - // Strip comment lines: the step deliberately documents the manual - // opt-in command, and that mention must not trip this guard. - var code = string.Join('\n', normalized - .Split('\n') - .Where(line => !line.TrimStart().StartsWith("#", StringComparison.Ordinal))); - - Assert.False( - global::System.Text.RegularExpressions.Regex.IsMatch( - code, @"new\s+install.*Microsoft\.UI\.Reactor\.ProjectTemplates"), - $"'{path}' must not `dotnet new install` Microsoft.UI.Reactor.ProjectTemplates — the Reactor " + - "templates now ship in the Windows App SDK template pack (`dotnet new reactor`). Install the " + - "legacy pack by hand if you specifically need the unpackaged `reactorapp` shape."); - } - - // Returns the text of the YAML step whose `name:` equals stepName (the slice - // from that step's `- name:` line up to the next `- name:` line or EOF), or - // null if no such step exists. Deliberately simple line scanning — enough to - // scope a Contains assertion to one step without a YAML dependency. - static string? ExtractYamlStep(string yaml, string stepName) - { - var lines = yaml.Replace("\r\n", "\n").Split('\n'); - int start = -1; - for (int i = 0; i < lines.Length; i++) - { - var t = lines[i].TrimStart(); - if (t.StartsWith("- name:", StringComparison.Ordinal) && - t.Substring("- name:".Length).Trim() == stepName) - { - start = i; - break; - } - } - if (start < 0) return null; - - int end = lines.Length; - for (int i = start + 1; i < lines.Length; i++) - { - if (lines[i].TrimStart().StartsWith("- name:", StringComparison.Ordinal)) - { - end = i; - break; - } - } - return string.Join("\n", lines[start..end]); - } - - static JsonDocument LoadTemplateJson() - { - var (_, text) = ReadTemplateJson(); - return JsonDocument.Parse(text, new JsonDocumentOptions - { - // template.json files in the wild use trailing commas; the - // template engine tolerates them and so should our test. - AllowTrailingCommas = true, - CommentHandling = JsonCommentHandling.Skip, - }); - } - - static (string path, string text) ReadTemplateJson() - { - var repoRoot = FindRepoRoot(); - var path = Path.Combine(repoRoot, TemplateJsonPath); - Assert.True(File.Exists(path), $"Expected '{path}' to exist; template.json moved or removed?"); - return (path, File.ReadAllText(path)); - } - - static (string path, string text) ReadRepoFile(string repoRelativePath) - { - var path = Path.Combine(FindRepoRoot(), repoRelativePath); - Assert.True(File.Exists(path), $"Expected '{path}' to exist; file moved or removed?"); - return (path, File.ReadAllText(path)); - } - - static string FindRepoRoot() - { - var dir = AppContext.BaseDirectory; - while (dir != null && !File.Exists(Path.Combine(dir, "Reactor.slnx"))) - { - dir = Path.GetDirectoryName(dir); - } - Assert.NotNull(dir); - return dir!; - } -} diff --git a/tests/Reactor.Tests/VersionSingleSourceTests.cs b/tests/Reactor.Tests/VersionSingleSourceTests.cs index 68ec47d88..d597537e5 100644 --- a/tests/Reactor.Tests/VersionSingleSourceTests.cs +++ b/tests/Reactor.Tests/VersionSingleSourceTests.cs @@ -105,27 +105,6 @@ public void DirectoryBuildProps_defines_ReactorPublicVersion() Assert.Matches(@"^\d+\.\d+\.\d+", match.Groups[1].Value.Trim()); } - [Fact] - public void TemplatesCsproj_fallback_derives_from_ReactorPublicVersion() - { - // The templates csproj's MicrosoftUIReactorVersion fallback default must - // derive from $(ReactorPublicVersion), NOT carry its own literal — that - // is what collapses the repo to ONE framework-version literal. - var (path, text) = ReadRepoFile(Path.Join( - "tools", "Templates", "Microsoft.UI.Reactor.Templates.csproj")); - - Assert.Matches( - @"]*>\s*\$\(ReactorPublicVersion\)\s*", - text); - - var literalFallback = global::System.Text.RegularExpressions.Regex.IsMatch( - text, @"]*>\s*" + PreviewLiteralPattern); - Assert.False( - literalFallback, - $"'{path}' hardcodes a preview version in . Derive the fallback from " + - "$(ReactorPublicVersion) instead so the repo has exactly one framework-version literal."); - } - // ── Guard 3: CI compiled-docs freshness gate is wired ───────────────── // // Widened for issue #1052. This guard used to pin a two-file diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 3ac234629..6a84f8557 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -285,6 +285,78 @@ public void TemplatesCommand_does_not_report_Installed_for_every_outcome() "deliberately keeps an existing pack (nothing resolved), and claiming an install happened there is wrong."); } + // ── Bootstrap wiring guards ──────────────────────────────────────────── + // + // Reactor's app templates live in the Windows App SDK `dotnet new` pack + // (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, short name `reactor`). + // The in-repo `Microsoft.UI.Reactor.ProjectTemplates` pack that used to + // provide `dotnet new reactorapp` has been deleted. These two tests pin both + // halves of that contract — the regression they guard is silent, since a + // bootstrap that quietly re-registered `reactorapp` would hand new + // developers an unpackaged template the docs no longer describe. + + [Fact] + public void Bootstrap_installs_the_windows_app_sdk_template_pack() + { + var (path, text) = ReadRepoFile("bootstrap.ps1"); + Assert.Contains("Microsoft.WindowsAppSDK.WinUI.CSharp.Templates", text, StringComparison.Ordinal); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch( + text.Replace("\r\n", "\n"), @"templates',\s*'install'"), + $"'{path}' must install the Reactor templates via `mur templates install`, which resolves the " + + "newest published version of the Windows App SDK template pack. `dotnet new install` has no " + + "--prerelease switch and resolves stable-only, so installing the bare package id fails while " + + "the pack is prerelease-only."); + } + + [Fact] + public void Bootstrap_does_not_install_the_removed_reactorapp_template() + { + // The in-repo ProjectTemplates pack was deleted; nothing in bootstrap may + // resurrect it by handing the id to `dotnet new install`. + var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); + + // Strip comment lines: the step documents the migration in prose, and + // those mentions must not trip this guard. + var code = string.Join('\n', normalized + .Split('\n') + .Where(line => !line.TrimStart().StartsWith("#", StringComparison.Ordinal))); + + Assert.False( + global::System.Text.RegularExpressions.Regex.IsMatch( + code, @"new\s+install.*Microsoft\.UI\.Reactor\.ProjectTemplates"), + $"'{path}' must not `dotnet new install` Microsoft.UI.Reactor.ProjectTemplates — that package was " + + "removed from this repo. Scaffolding goes through the Windows App SDK pack (`dotnet new reactor`)."); + } + + [Fact] + public void Repo_no_longer_ships_the_in_repo_template_package() + { + // The deletion itself. Assert on the tracked *source* rather than the + // directory: bin/obj under tools/Templates are gitignored, so a + // contributor who built the project before pulling this change still has + // the folder on disk. Checking Directory.Exists would fail for them while + // nothing is actually wrong. + var root = FindRoot(); + foreach (var relative in new[] + { + global::System.IO.Path.Combine("tools", "Templates", "Microsoft.UI.Reactor.Templates.csproj"), + global::System.IO.Path.Combine("tools", "Templates", "templates", "WinUIApp-CSharp", ".template.config", "template.json"), + }) + { + Assert.False( + global::System.IO.File.Exists(global::System.IO.Path.Combine(root, relative)), + $"'{relative}' is back. The in-repo Microsoft.UI.Reactor.ProjectTemplates package was removed " + + "in favour of the Windows App SDK `dotnet new reactor` templates."); + } + + var (relPath, release) = ReadRepoFile(global::System.IO.Path.Combine(".github", "workflows", "release.yml")); + Assert.False( + release.Contains("Microsoft.UI.Reactor.Templates.csproj", StringComparison.Ordinal), + $"'{relPath}' packs the removed template project again."); + } + static (string path, string text) ReadRepoFile(string repoRelativePath) { var path = global::System.IO.Path.Combine(FindRoot(), repoRelativePath); diff --git a/tools/Templates/Microsoft.UI.Reactor.Templates.csproj b/tools/Templates/Microsoft.UI.Reactor.Templates.csproj deleted file mode 100644 index 9d4152501..000000000 --- a/tools/Templates/Microsoft.UI.Reactor.Templates.csproj +++ /dev/null @@ -1,92 +0,0 @@ - - - Template - AnyCPU - Microsoft.UI.Reactor.ProjectTemplates - Project templates for Microsoft.UI.Reactor - Microsoft - Project templates for Microsoft.UI.Reactor - © Microsoft Corporation. All rights reserved. - Icon.png - README.md - MIT - true - dotnet-new;templates;WinUI;WinUI3;Windows App SDK;WinAppSDK;Windows;desktop;Reactor - netstandard2.0 - true - false - content - true - $(NoWarn);NU5128 - 0.0.0-local - $(ReactorPublicVersion) - - - - - - - - - - $([System.String]::Concat(%(RelativeDir), '%(Filename)%(Extension)')) - $(IntermediateOutputPath)$([System.String]::Concat(%(RelativeDir), '%(Filename)%(Extension)')) - - - - - - - _UpdateTemplateVersions - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/Templates/README.md b/tools/Templates/README.md deleted file mode 100644 index b7ead78eb..000000000 --- a/tools/Templates/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Microsoft.UI.Reactor.ProjectTemplates - -**`dotnet new` templates for scaffolding [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) apps — a ready-to-run WinUI 3 Reactor project in one command.** - -> [!IMPORTANT] -> **This package is superseded.** The recommended way to scaffold a Reactor app -> is now the official Windows App SDK template pack, which ships first-class -> Reactor templates alongside the WinUI 3 XAML ones: -> -> ```shell -> dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates -> dotnet new reactor -n MyApp -> ``` -> -> Short names there: `reactor`, `reactor-mvu`, `reactor-navview`, -> `reactor-tabview`. Those templates scaffold **packaged** (single-project MSIX) -> apps, so `dotnet run` launches with full package identity. -> -> This package is still built and published for the **unpackaged** -> (`WindowsPackageType=None`) shape, but `bootstrap.ps1` no longer installs it -> and it is no longer the documented default. Prefer `dotnet new reactor` unless -> you specifically need the unpackaged, zip-and-go project layout. - -## About - -This package installs project templates for the .NET CLI and Visual Studio so you can create a new declarative WinUI 3 desktop app powered by Reactor without wiring up the project by hand. - -## How to Use - -Install the templates: - -```shell -dotnet new install Microsoft.UI.Reactor.ProjectTemplates -``` - -Create a new Reactor app: - -```shell -dotnet new reactorapp -n MyApp -cd MyApp -dotnet run -p:Platform=x64 -``` - -This scaffolds a runnable WinUI 3 project that references `Microsoft.UI.Reactor` and the Windows App SDK, with a root component already wired up through `ReactorApp.Run`. - -### Template options - -- `--NativeAot` (bool, default `false`) — configure the project for Native AOT publishing. -- `--UseProgramMain` (bool, default `false`) — generate an explicit `Program.Main` instead of top-level statements. -- `--Framework ` (default `net10.0`) — choose the target framework. - -```shell -# Example: a Native AOT-ready app with an explicit Main method -dotnet new reactorapp -n MyApp --NativeAot --UseProgramMain -``` - -## Included Templates - -- **Microsoft WinUI Reactor App** (short name `reactorapp`) — a Windows WinUI 3 application using Reactor (C#). - -## Updating and uninstalling - -```shell -# Update to the latest published templates -dotnet new update - -# Remove the templates -dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates -``` - -## Additional Documentation - -- [Getting started guide](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/guide/getting-started.md) -- [`dotnet new` documentation](https://learn.microsoft.com/dotnet/core/tools/dotnet-new) -- [Custom templates for dotnet new](https://learn.microsoft.com/dotnet/core/tools/custom-templates) - -## Related Packages - -- [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) — the core declarative WinUI 3 framework. -- [`Microsoft.UI.Reactor.Advanced`](https://www.nuget.org/packages/Microsoft.UI.Reactor.Advanced) — optional Win2D/graphics components. -- [`Microsoft.UI.Reactor.Devtools`](https://www.nuget.org/packages/Microsoft.UI.Reactor.Devtools) — optional developer-loop devtools host. - -## Feedback & Contributing - -These templates are part of the open-source Reactor project. File issues, ask questions, and contribute on [GitHub](https://github.com/microsoft/microsoft-ui-reactor). See [CONTRIBUTING.md](https://github.com/microsoft/microsoft-ui-reactor/blob/main/CONTRIBUTING.md) to get started. - -## Support Policy - -This package is currently released as a preview and is provided under the [MIT License](https://github.com/microsoft/microsoft-ui-reactor/blob/main/LICENSE). APIs may change between preview releases. diff --git a/tools/Templates/templates/WinUIApp-CSharp/.template.config/dotnetcli.host.json b/tools/Templates/templates/WinUIApp-CSharp/.template.config/dotnetcli.host.json deleted file mode 100644 index 375341020..000000000 --- a/tools/Templates/templates/WinUIApp-CSharp/.template.config/dotnetcli.host.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/dotnetcli.host", - "symbolInfo": { - "TargetFrameworkOverride": { - "isHidden": "true", - "longName": "target-framework-override", - "shortName": "" - }, - "Framework": { - "longName": "framework" - }, - "skipRestore": { - "longName": "no-restore", - "shortName": "" - }, - "langVersion": { - "longName": "langVersion", - "shortName": "" - }, - "UseProgramMain": { - "longName": "use-program-main", - "shortName": "" - }, - "NativeAot": { - "longName": "aot", - "shortName": "" - } - }, - "usageExamples": [ - "" - ] -} diff --git a/tools/Templates/templates/WinUIApp-CSharp/.template.config/ide.host.json b/tools/Templates/templates/WinUIApp-CSharp/.template.config/ide.host.json deleted file mode 100644 index e3cc4ca00..000000000 --- a/tools/Templates/templates/WinUIApp-CSharp/.template.config/ide.host.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/ide.host", - "tags": [ - { - "type": "projectType", - "add": [], - "remove": [ "Common" ] - } - ], - "symbolInfo": [ - { - "id": "UseProgramMain", - "isVisible": true, - "PersistenceScope": "Shared", - "PersistenceScopeName": "Microsoft" - }, - { - "id": "NativeAot", - "isVisible": true - } - ] -} diff --git a/tools/Templates/templates/WinUIApp-CSharp/.template.config/template.json b/tools/Templates/templates/WinUIApp-CSharp/.template.config/template.json deleted file mode 100644 index 843ba0aa3..000000000 --- a/tools/Templates/templates/WinUIApp-CSharp/.template.config/template.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/template", - "author": "Microsoft", - "classifications": [ "Windows", "WinUI" ], - "identity": "Microsoft.UI.Reactor.CSharp", - "groupIdentity": "Microsoft.UI.Reactor", - "name": "Microsoft WinUI Reactor App", - "description": "A Windows WinUI Application using Reactor", - "shortName": "reactorapp", - "tags": { - "language": "C#", - "type": "project" - }, - "sourceName": "Company.ReactorApp1", - "preferNameDirectory": true, - "sources": [ - { - "modifiers": [ - { - "//": "Binary assets must be copied verbatim; the template engine otherwise runs sourceName / version substitution over their bytes.", - "copyOnly": [ "Assets/**" ] - } - ] - } - ], - "symbols": { - "TargetFrameworkOverride": { - "type": "parameter", - "description": "Overrides the target framework", - "replaces": "TargetFrameworkOverride", - "datatype": "string", - "defaultValue": "", - "displayName": "Target framework override" - }, - "Framework": { - "type": "parameter", - "description": "The target framework for the project.", - "datatype": "choice", - "choices": [ - { - "choice": "net10.0", - "description": "Target net10.0", - "displayName": ".NET 10.0" - } - ], - "replaces": "net10.0", - "defaultValue": "net10.0", - "displayName": "Framework" - }, - "skipRestore": { - "type": "parameter", - "datatype": "bool", - "description": "If specified, skips the automatic restore of the project on create.", - "defaultValue": "false", - "displayName": "Skip restore" - }, - "UseProgramMain": { - "type": "parameter", - "datatype": "bool", - "defaultValue": "false", - "description": "Whether to generate an explicit Program class and Main method instead of top-level statements.", - "displayName": "Do not use _top-level statements" - }, - "NativeAot" : { - "type": "parameter", - "datatype": "bool", - "defaultValue": "false", - "displayName": "Enable _native AOT publish", - "description": "Whether to enable the project for publishing as native AOT." - }, - "HostIdentifier": { - "type": "bind", - "binding": "HostIdentifier" - }, - "csharpFeature_TopLevelProgram": { - "type": "computed", - "value": "UseProgramMain != \"true\"" - }, - "MSUIReactorVersion": { - "type": "parameter", - "dataType": "string", - "replaces": "MS_UI_REACTOR_VERSION", - "defaultValue": "MS_UI_REACTOR_VERSION_VALUE" - }, - }, - "primaryOutputs": [ - { - "path": "Company.ReactorApp1.csproj" - }, - { - "condition": "(HostIdentifier != \"dotnetcli\" && HostIdentifier != \"dotnetcli-preview\")", - "path": "App.cs" - } - ], - "defaultName": "WinUIApp1", - "postActions": [ - { - "actionId": "B17581D1-C5C9-4489-8F0A-004BE667B814", - "continueOnError": true, - "description": "Update Microsoft.WindowsAppSDK to the latest stable version.", - "args": { - "referenceType": "package", - "reference": "Microsoft.WindowsAppSDK" - }, - "manualInstructions": [ { "text": "Run 'dotnet add package Microsoft.WindowsAppSDK'" } ] - }, - { - "id": "restore", - "condition": "(!skipRestore)", - "description": "Restore NuGet packages required by this project.", - "manualInstructions": [ - { - "text": "Run 'dotnet restore'" - } - ], - "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", - "continueOnError": true, - "args": { "files": ["Company.ReactorApp1.csproj"] } - }, - { - "id": "editor", - "condition": "(HostIdentifier != \"dotnetcli\" && HostIdentifier != \"dotnetcli-preview\")", - "description": "Opens App.cs in the editor", - "manualInstructions": [], - "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", - "args": { - "files": "1" - }, - "continueOnError": true - } - ] -} diff --git a/tools/Templates/templates/WinUIApp-CSharp/App.cs b/tools/Templates/templates/WinUIApp-CSharp/App.cs deleted file mode 100644 index ef2b26ce7..000000000 --- a/tools/Templates/templates/WinUIApp-CSharp/App.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Microsoft.UI.Reactor; -using Microsoft.UI.Reactor.Core; // BackdropKind -using Microsoft.UI.Reactor.Layout; // FlexDirection, FlexJustify, FlexAlign -using Microsoft.UI.Xaml; // Thickness, HorizontalAlignment, VerticalAlignment -using Microsoft.UI.Xaml.Controls; // Orientation, InfoBarSeverity, etc. -using static Microsoft.UI.Reactor.Factories; - -#if (csharpFeature_TopLevelProgram) -ReactorApp.Run("Company.ReactorApp1", width: 900, height: 600, - icon: WindowIcon.FromPath("Assets/AppIcon.ico")); - -#else -namespace Company.ReactorApp1; - -class Program -{ - static void Main(string[] args) - { - ReactorApp.Run("Company.ReactorApp1", width: 900, height: 600, - icon: WindowIcon.FromPath("Assets/AppIcon.ico")); - } -} - -#endif -class App : Component -{ - public override Element Render() - { - var (name, setName) = UseState("World"); - - // The title bar's app mark is inherited from the window icon set via - // ReactorApp.Run(icon:) above -- no need to restate it here. Call - // .Icon(...) only to show something different, or .NoIcon() for none. - var titleBar = TitleBar("Company.ReactorApp1") - .Flex(shrink: 0); - - var body = Border( - FlexColumn( - Heading($"Hello, {name}!"), - TextBox(name, setName, placeholderText: "Your name") - .AutomationName("NameInput") - ) with { RowGap = 16 } - ).Padding(24).Flex(grow: 1, basis: 0); - - return FlexColumn(titleBar, body) - .Backdrop(BackdropKind.Mica); - } -} diff --git a/tools/Templates/templates/WinUIApp-CSharp/Assets/AppIcon.ico b/tools/Templates/templates/WinUIApp-CSharp/Assets/AppIcon.ico deleted file mode 100644 index 09dea37931747eb92e26d018b6b505cafc9b3329..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34494 zcmeHw2Y6Ohw)ROcKq8<63Mzw+qGNYvL>}P-VoVC_od+$}= zwLeAiP<)hYu2IkrP{w#G%1er(^zEy@o)@4f`|;j@0qX1Z_};TYigMq5+Uq|kO7%^O z^28JB>p@Q`%Ce^wWe~1`ui}d8`{@2h9_iDRQD>)lgr`mSh)A2^5phno5RdS)vwfF# zL`3!{o>Q`Bc~RDEFUscjsTbwU;pR=bb9~HEZ+K9`Wbbl!*C<#J&@caUpT>guK2&?= z8*0q{iE2|8QPBclDn`p$_pIsHR}|Xtl0s)@`HgVCM(Gm2wPj!VQRTr8iE4KfRd1uJ zxbal+jX#yGx~ajMHJ(y_{D=7c-Z5Slp*nm%N%NhEvyL24`f_TZ#Yg3 zd!M70wcV+FMURS8OCBZ4dxt1{IOXknlOn*wR>&eZRPi@Q1V3or(1Tic{DrK|d1P}Z85PfRDu8BMm#H)z*;{RIuDX813sEx`|>ktOySvYTtk zZm9teNkqH*5$(H~Ql|UZ4~$i)>eNioFoA4YBWdsC{uJ>t#s z{JvhjM%omjL$^`Qul*`gm;H^XFqA0&Jt~NS42CMSYow>3U&vrv&xdJ8AG z$ZoACYfCZF(YuLG+z;Ak+o@k3HRBEnYyG8(*WnBNRINp^wA(2^wN=?N5gXHPX?0$kdQUmXPkTq#FInvSeCc98!kSH`U-uwW z`5vO0Xsp>=$zHjM$~Qep7nb@_`PcqbzC74u%A8EC=f_dxFki8Du6seDq$$2f-Q9yC z6kn|QnH9_YiCfLdMMQN6uqVXe9y_W2+zP^4Pu1V4?ZTl6R>~cNwfRlT+B!t%Vbg0K zl)cy=JG0}X`g{LrH7f$BWAyV`!~PG@HNTJm>~SN9g|4V3)`mBo)H-Y0n+xTS^pyejX}ytdo-yeGAyHSPU7 z*_yM+-kgqmoT1k0W7N8#H<{P>Al{FvRt1;GVXfRh&X-!VnTK&=|Dvp+ba3{)Iu9}9 zyiD@?=7=6umWUv-ZVaO4D(r);1=!EBagTGByU|l6xFLA zZ3$}Ny_TXN>$)`Dqd~hzEtyNAhz{Z&@q>tt{E^Pj@wXK1|A46I6QTm_$9eBk`O(R= z{dL$6*n=a{(&q*am-KT_+j>o>?bksTeaRaCEUu0HT;9W6MYa?FL6mqG(eZn!b^q

^_TxOU;C#L&4=%=`T^sRo7b0+P*QP>y4W)S-|^HERc z`c$H%`$_DhryruqV5o(CR6DSy{k+JpUj=UWQx9qmEI-?`2&agUgrUZB|D6CJpf z>P^Z_q{t@-}s(%^o~E$(Yq|LDTq!! zNObxyI?d9iF!rZIeC%wWu)Y>730l}5-NyG}TP>mFKNF=4cA(dIUHQCj$E_8tuW7UW zJGH+E9y0#J4{~0BYgEbWbkJ87Ex#tUadqeSMc`4WXQnCtI6FmoQE$`n+^L`+hhp?AF-Gv@f-d8u?{ zgnESY^)k5$yHmA2Go{_B`R2NRuKDO}gCv`eO21$D{%oVl zChb(&!&c3k>s!BNsE6>)!(kVln(n)&v-joi!RGVIo#&O3Cv9K!gjo7v2-tPUdSBed$X@ zivp;nWGn31NZ8EqVXHR4ZVpF0W1+B@*=81PF}}O@p}MBC6JalS3EPux9+m<2n1asS zJb#(e6W6LJ#kGV@4ZFH30lt3iF4(&}giTFVo5@EvoH3oGclmD0)m!T?T9)$4>C-aIdOd?t5p&vH&w*i*0&tbsU4KKdjpPb^AfX z9%?PyK{YG8!)90Q_FCBP)hl|NOxZJF*TR0z837r*PDQaJ9b~}ckR0MSzdZ?5uL#)1 z*Wx&lXpxVTt$}Uzqo5%{_yklJOLcpmSABxj-KZX|0WB~5DHG+tui1=jyT3*DtT!lj z#_bL=*#4G#U#fmp!2glgVt;{KQ|STCUpNUGj-eevEGd$je!^!z!RFU|huT#=YU9Rw zP;DygDA<(3wugVhws+0R@w#oydJz6R?7%6$@kSZ2kJb2dw@jliQXfAScK&&^v!DUC zf8!~#!8dB&@i4W9Bff=ZTBo)%p}tnO`x9s1L3Uv`vwsBJA3hjmy+$XNKjk0;_Qi~T zw`pzA5P2=pNA#xFx^t?pf%uud8Fn>%nx>4^#QunA@JZM&sahFS&URKT>@2qBO5ID+l!0YqkMH<`)5U)c*kBDycpt{WWuqImd4($+jQxffaTFb~-O5`HktUvrV#gcq zzq!Xy$&cu_^&y+7O!YM^Y7A2NWaU@`I1VX%g#kpd1K~6OPW78W1IwT+VH$j!k8%I^ zG(8*vUw;Bcz3EGvUsHW3UW?$1oRRWp{ev%#jACCP*j$0e`kLkd@|6ANYvM15D z-o!qar8(b;hFY@Jrwc!ZW2wU5fM3_L`mu#oCmQg#Q%< zACLVzmci+;fWwk^V^5JicT?20L_2<``ghRGVaelDASNJuUot8 zFVjN1`h6{ZQPwY8;uGMT`PckKO%A3D(W)ON{H2G8QXiqpLt|Q~WWMl!t@)o){0F^7 ze3|`6_EC>d@oh2k>+U%nx~V7htJ0`nrt(?@{b66OH=CvbLHLw{W1LflT%1T>Qk^gU{79=Pag! z6TF1)xgTpH$Jo>6_!m0gr@hw{`?xn9zB5I{6@-2v50LubWGdZEEO%2mS_RsL%`W$6 ze=_H*o6Ni>@p`~ANO;GS>c8uLZF~1~_daqB|Hd&5sb8wUJy?u$2R^Hyr}fDFbvz&C znwYz=dzb7_Ne}KFuKMGueqHRo#?SMY1~sq@&V=~7FV<7)TEX5_vRqG>tRb90C>$=s!kF@<G;RK4cp)Jw4V<3eZk?T zSLj93D8&c4-9sYBi@2Gj!8ML1V`^UH5Koh_HN?^!;%a!EIoqe6{Rj5nwtz9g6HZN}rrhz3S9hp^6h~ zj~16aw=sTbi2KRhch)?=3Yp_$`gsi2gO_u&d|tX#dYyll_nFsfJ@3x+a4gdeEe^SO zEg#Q0dA-Saq@mwG-pkJMxQ&QG7{?%Hte4rDj!Su+_qopl`SX2jIt>~<92YGK{~NWI za_kcJdeuh6E;)X=j;P`nw4adwUn6i?jLnkxEXHUtCV*Svw&z+-E`LV&&N8mLK^p^( zrKE)Tue=nR@VY+5{*Mp>^5JOj@GeGH)gJP+17 z#k0CXf58GJplpe6ei^VEk`A4gmA@xz4aZ#fw#9yHwu63PM-U6QR&1e)H3O*}m^^Nb z&C?sO8l^uDtheP%LM#xm$m}-+USj9OK0=S2$KcmjJb=sTdaeXAnkC}Yj4k12prtMo zI$p=IYvv(F<6);3M~|YKW1(978nI?W%vr{st51Hc#u0Nkj`tSEU>NWiV;$##HU>No z4!+|Jv%B+;s&D*nto+8m$rwkkWIDJ-K8tuO$D9vovF})o2ViTUPp0x6RP{qJRez5d zxUq?tcmS1ceyUE51#>)nv_1yR!@=42sAGT}iE%CBe9@WNeq%e!LG`x*8zkL5Ncn)zu5rgDd^f=JZ@o>yz;^n77|0#_J&K)og_AqoRo*JSaBgVI?c?8)bpMab~M^3@n7<4i>?S>#pJF)*DUN!#8EbXkjd47LpOpbK9s-P_}#X-(iLzQ3_a zS{l-R(Bl26;2}lhfpaj7%Sr?$cMUZIE7c+!uuqJEk}Z8sKr6@LIqp_*9I@#9$%w;Z z4Kj`a<^W~AO1aUmyNrQcPZK}z$#zFS{oE@Ed6ElEgKUzP=E!@1W8!=T=S>)gC37bj z59pJH^Cz1IQmd9XamaZ9r&af3kE#Ql%K-hH%ZQ)aPhfb|dCX&AoX10e9Uew|fc>JB?xtXA!=dtrzI)o_mWlEU;XE6qHPvdoz=fSquU2}cNHC2l|lf1vyHkrj zG>n0kBjb6%dxNw5jhH>-{Vk2qd6B1Kd|g}4NaU`7Q!}5QB62De-ACj$M2K(%p79~tAD<>JYf>(cN&8O6aC4Q!+QbeC6SwTSxbje z;+GH67UYH4wqyLF#4WhX6@%V)CC$#gMc@yae)l|>^23og)be+bIqe&O9NzEMTpF-> zX|w!n#xdZzTo?nI3puBUy}->{T6t`uz%FubP3HIH7##h`r`SdQ(Y=B|_XTI#`L#9n zNmX}5j>ioTrVC_?e?;IEMGi1_pqh(gj3x4MEm1dCCBEY!^oZv{Vn~0o6{=WBfwz=n z!1EYusV!#=?f>W|kr(6KoE!sQPqz%g`nfQ`W&LD+fcYHlKtJO@xAqiyLv#5-=n?0u z&>Y4=ofGEjBMvebd7k*&wOrzzl(V2)iyVWhxHaYYXP58F+RzXX%)?l=)LjQ?`o9WxVJS(9ZZ- z&@S>#z;_mZ-?OGEdnr)~(~o?r$Y+K?{+w4<#~=;(QO+qwYk5uPL11_>57IyN@6dkJ z$6;IV2-}JL?BOhIRc7ZkqZS%PN z_zYp&aZXm`#^o4*_ER(by5v6!`nO#lNHI6&b8VQ4i)dRDtx12!8pQIK&4~x(u&rq? zYcglL2MfAoez7I->3UnyV(@?%1~BE;!a0;M?FQnUGTYb8!)`6VA2Z5>^5$Gxt`YJc z%DH|$=g!y)ZA~;)!gmpQZ)5(wZGAO2WI*PiTj0mBH)!=?vadWo!>VEeKBI!&6NPOm zZM(hTL5{(>xdA#JMb6L8_8h+gw*8RAqZrpj)^Qc@&h)p#LoW6xC%WZp6%W6ZTJsiB zJZcj}P90b$;}{%%*Xy$4S3-{t-0}b&8f2pcUK6pFi#3tgm3zsOH3K}b%v;>@U~R2v zmwx7>;?Ss;^J||cuHSI6uQ3KWi+WsE{7{Y)@HlHIQQe2!t%;QEI6tL~JJX*5o}X<~ zXA4BsmfzZGL}fmIbKeW`iQAwE$12uH)~=?G+ykpjJW}3qkVmTZeYdb(CY5Dl=L6~ z#D9v{W$v^lUW~?$K5tFDzy9cZ9>T{-`pC=vTh&(3iAQdLt|Y2!;-izcjev z

jv`S(FrEbroW98&M}6}7jik0zk*wz@rfI{&`7#$jvP!{Ztw{{O6LaVN*C?_HI^ zm5>052Ylc5dIlUJ+Cq5%AaNfDFp{wk1HOn~%U;0)uYpMd;;F3{&d1}5B75MMfPzv` zqowx3K<4^*e(l%R$MI{fm%qe5kAG*lp1hB|uk0mVvUjE*ED64JKEpJ=-EB=%|&BN3lPEq0( zUGRYh%%V$;?98_FR~RqofEk?WX+rMs)Y;kof9o>ASMUdEp~_w9As#sne1YyR@!|&m zsACU-M>Jv~`VMZ^89c2t@+AQBHp7)$Dl^7tY z1Cg(HjG?&#tcy#4*U=8=O&R&p@UJcySjNO^=TRAZc{Y_Z235XxsMVkEYmOf7q2e(i z19v)r{BgTE%qzivMyL{$Ki9j|$b)=tp#4M*ILojxdkxxZv{h(7 zpsf_=0r0s2ZD$8)=LayR)d|;{757oNz3s@#+ru#o~C3tmr4UEge0s}_b zVSM;~@N!`nu$vCp+fBf&ZUpvr1KK(?UaSsxYDCa@kMDHedv|sLf1q=Lc#h92Ju*QseHnftZ!DKGq3B2fb4fDDcy~OT9 z1_Fb;UdW)OXe}_v{)Ds8$QgsIpN+=nqoGwDf7i} zOClbtYr}F5quiZuUQ`F7-}baQ(|abvH_RenEo-app3r)1`hZoTh?2^@ZIR50|H;K z>43n5PV?P&IXuhX(szmv#^#*!xG>h-*cx&%&vo4NUdUiKcxM?zqsjRYiE{)aQ9>|C z#eHjM^vSdOD)CxxI4h)N*K4gRmUk3*S25v?=dC!Z;srj&7<++3 z1%5{A0C4>*gM|4Hx#|F8%MCh!Gh|A-%CGVJB}=+rSFy~m#yBR9*mFM5r5iP00Cu~c zG1~_0xsU;4=%oyHVr>Klebu%4*^AB?a2=NpOnSwU_pB-ocRXY;M#FOthaS8k_-{N5 z9l$v~azAvo2iPuNkG1e}>c+t9clwy{`7+v>kvfK*$LVO8z!|G&Kr-fA${@~VKGejG zCUIVj@ywW&hVx^z=jGvd)!Xtg_5%Mm(Vzo72i^q#!_Z!%v*FL_{0nvTwJ~ivP!RLB&c9Iyq<_qN!L~O&x`g*=SRL@991FcS z)D3X$K>e17aMn=@wbczk2BQ8!s~u=y8L0E2G3yrx?7nL)fqFhs!_VDqiIcqqR;VT= zOw|EC7e?ao1)jZaFXVG-?3qJp-;`T)8MxDdwa@{a7kk_|zU@7y)rvpHBUg?I_dG7f z+5_>t1lV_h*FU4lK*j90$v~YCmfFMAv}sV=xvkn6OtKb0_FNN?vml_3F(Qn?J`x68 zW!{uF9pE!$1s>jUFJw%7by8c6k;DT_yI9tUY{jske({+I7f#hHM1*`*yCVN*&KHc?A;Q==^ z{nD+`I3}`IL(<-|?iy;T0lyl~UX?)__}65h*U_9HQ*|P>uJ5g%d!^TJpmxKgHF4f9 zp5v_7R>t5n)=cOCWDq;CJBjmyJ6s2cg8x^kC4Dr`tnzm2A4?rzS)_&ew~OCt3GX&b z9T#;>jCCKf2BqQ55<|TN*BWr$hgvh?I3G??Q{s5N?t<$tMD2x}T9EqHL6wZtk+mQa zZ^t^2@hkXq(E-c>r*)8Z;QXeSE~*3V)!gwJf*6w$=W&teAv680TLzM)33V2F&4^a} zBF-FR8MM{Ea6OwmAIzmk4TsfG&%t#t5+|nXfSd<7C%5rjC}belLFc`Y?LnRcub^IX z80{Q)ty>-7vFE)Y0(Fz_YOPw<1>NGVMu*>%^f%^h*UmXBgbZZOiIjm_cfvBTHKBfH z-_xQ_hHGWG*+s+I&xSLg`7G%2l|dB(qov`@-08qc{{IA78`r^J$U0CGH=<)5fDBT? z{3bfjf6K4IPxF`<$HiFlV~y-1{?kCMxpA($k@Z8W3>x#`^K0i+U#veh#Y?YaVIANz zv0Zck^Pn60ouY19u9_Tc;AzoWhIH7YrXQWLGVrak}I zo&Iw@J9sSZNCviDe-z`}85vj_($w?V`RqZi+cJ!~iaj5o;pPWoPx7^~4zLU)-c(-) zfH`f>L`?;sf6nzd?XH7dFFu0yO#Gb#|IYDpjtjV3|9C!t_fI?GpFcAfZiWspwwW>g zDz3k6KJYw{a{*`4XME{YKZP-u^#T&VjykHQt=Cs3q87|W2e1}oZ-st~^8u$q1|Mnb zpwt0JpCDrk=056rA~F8$=>X2~5KtxO`7ij7?0o}m4r=UB22t0M%~aYc8Q8eKlF!W7 zYCKfl2kP}>YE95>0=u96MPRG54jldfXJZzi9;Fa-U(}SbPvl+)TF$>i+u!mQbwN@F z&hhe1$Qz`6-Z6d|wOS!=`RAG~KI?I7FEXdX=Xa=4an=L5CO`*D5c`s2j{moz1Gk_S z>Q*%Hedu<*uE-Sghvwsx)mk)(8!t|75fYLVazvJQxvHcbZ%>tIdthx&PuvNp_F2eu(UmfZ>eN6drGK`#mZ z9r*68^UiykCGiDy{_8Tpn&7e?sCr(UmW zh6cLN|IHxk$!0lU5)7WHWN;#|`EsA_%RigQ2y z12sfgJ4HR+XPOSkIpDYs7ROB@S(j$4t<&cL_}@Fa>-8?QJ@`J<3R-FVwK!k(I(0AS zvvm1CMzlT6cHmE_a{;eH2b^Uf*96vqTKuitg+f*f9HVC94T`uS?CGoC{J9>QE!j@H znn!0DU_NMiV9s9zeTS|)jk%F`eqMV3bJd0fbV2ARbZZDE$l>XVr}XdcHRrk`3rFd zVK<$BW2{u_0N3Z{EbQ@{U?Rc$BBRajV!vTeGZo=1ejD>IWgzDR%SEmS>bhvE*xf1q z)w=3Ml!gDlbn%a6oDg$>bs#0g?>E7}u)aVCuECg>JJ$T(O=|6|YQM1^V=i1l=H_Dj z2WL6j1+A5#m#q}fOUcxj)hYg2M~b(@eoyOas`iT)%s& z<2|iC{yOvig*Dezxzz#aLBe~URI@1fnoHrStG}Vdd!KZ+*JUPTyl-FGjKVAJT zrQF>U{AcTR-@h^6M%mz;ez6wvUXTdAFZ!<6(_N+iN_?MA55AjH{*tSn0|I?Nd*$`r z$hUmnn)pBi*To-u-;>I|37&f;c)8STQ0m}b@K=5YZDvW||3GglU3G18#dp8$Ue^_w zP}*bHQ`%q0f%k=2_cl2F_qFqXVR!wtxF)WB_K~sppNqa-q3!B-uS(#m1g=Wpssvmm zK%IWX@qq+*@HNn>2N@8l2OG5Y0&RLTaKHS$;I=-GU;80{t@Ux-{mtTb>i^#G`~2TGqR&%%KiS8ruf00k%iqafURU<=zLE|} zm!wnnk`KKPQ}6(vaoijD&*Rqy`8dl@%2nLx@iuvj8--#(`1k1e!eX5G`Nbdq7bN@! Ag8%>k diff --git a/tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj b/tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj deleted file mode 100644 index 4f40b52d4..000000000 --- a/tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj +++ /dev/null @@ -1,80 +0,0 @@ - - - - - WinExe - net10.0-windows10.0.22621.0 - TargetFrameworkOverride-windows10.0.22621.0 - - x64;ARM64;X86 - true - None - - true - 10.0.17763.0 - 10.0.17763.0 - enable - - Assets\AppIcon.ico - - $(NETCoreSdkPortableRuntimeIdentifier) - - true - true - - - - - - - - - - - - - - - - - - - diff --git a/tools/Templates/templates/WinUIApp-CSharp/Properties/launchSettings.json b/tools/Templates/templates/WinUIApp-CSharp/Properties/launchSettings.json deleted file mode 100644 index a7aab3de1..000000000 --- a/tools/Templates/templates/WinUIApp-CSharp/Properties/launchSettings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "//": [ - "Default F5 / `dotnet run` profile for the scaffolded Reactor app. The devtools", - "profile pairs --devtools with the Debug-only `Microsoft.UI.Reactor.Devtools` package +", - "Reactor.DevtoolsSupport host config option (see Company.ReactorApp1.csproj).", - "Release builds exclude the devtools package, so the default profile does not pass", - "--devtools. The Reactor Visual Studio embedded-preview extension (spec 056) launches", - "with its own argv (`--devtools run --embed --embed-host-pid `) and does not", - "consult this file." - ], - "profiles": { - "Company.ReactorApp1": { - "commandName": "Project", - "nativeDebugging": false - }, - "Company.ReactorApp1 Devtools": { - "commandName": "Project", - "commandLineArgs": "--devtools", - "nativeDebugging": false - } - } -} From faeeace58fc471c7414f2319808108ac3cea1f06 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:38:31 -0700 Subject: [PATCH 04/37] Address PR-review findings on the template migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the repo `pr-review` skill (8 dimensions + multi-model cross-check, all 6 high findings confirmed by the second model). Fixes below. CI BLOCKER (introduced by the package deletion): docs/_pipeline/templates/packaging.md.dt still resolved a snippet from the deleted tools/Templates/. SnippetExtractor raises REACTOR_DOC_SNIPPET_001 on a missing source file and CI runs `docs compile --ci`, so this branch would have failed the docs-build job. Replaced with an inline example. Stale docs the deletion invalidated: getting-started.md.dt and packaging.md.dt still told users to install Microsoft.UI.Reactor.ProjectTemplates and run `dotnet new reactorapp`, and still described the scaffold as unpackaged. Both rewritten for `dotnet new reactor` + the packaged MSIX shape, and both guides recompiled. (These two templates are also being rewritten by a parallel workstream; this is the minimum factual correction, not a redesign.) Destructive-install guard was incomplete (high): An explicit --version bypassed it entirely — the pin was trusted without any existence check and then paired with --force, which uninstalls before downloading. A typo'd pin would destroy a working install. The decision is now a pure `PlanInstall` table: --force is reachable only for a target confirmed present in the feed or folder; an unconfirmed pin is refused with the existing install left intact. Credential leak (high): The echoed `dotnet new install ... --add-source ` line printed feed URLs verbatim, so a PAT in user-info or query landed in console and CI logs. Added RedactSource; local folder paths are still printed in full. `install --help` performed an install (high): Help was only handled before the subcommand. Added install-level help and strict argv parsing — unknown options, stray positionals and flags missing a value are now rejected before any side effect. Install outcomes were pinned by source-greps, not behaviour (high): Replaced the regex-on-source guards with a table test over `PlanInstall` (7 rows + an exhaustive property that ForcedReplace implies a confirmed target), and extracted `DescribeOutcome` so the reporting bug is tested directly rather than by grepping for a string literal. Also: - --source URL was ignored during version resolution, so a custom feed silently resolved a version from nuget.org and installed a different package. URL sources now require an explicit --version. - Refuse plaintext http:// sources for a code-generating template package. - Extracted InterpretInstalledVersionOutput with tests for the present, absent and malformed listings (an adjacent package id must not be matched). - `mur upgrade` returned 0 when an explicitly requested --templates-source / --templates-version install failed; it now fails, while the routine no-argument refresh stays best-effort. Mutation-verified: reintroducing the unconfirmed-pin force reddens both PlanInstall guards. Exit codes checked directly (help 0; unknown flag, missing value, http source and unpinned URL source all 1). Suite 14,163 passed / 0 failed; Release build 0 errors; both guides recompile. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../_pipeline/templates/getting-started.md.dt | 136 ++++++----- docs/_pipeline/templates/packaging.md.dt | 35 +-- docs/guide/getting-started.md | 136 ++++++----- docs/guide/packaging.md | 79 ++---- src/Reactor.Cli/Templates/TemplatesCommand.cs | 105 ++++++-- .../Templates/WinAppSdkTemplates.cs | 230 ++++++++++++++---- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 8 + .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 197 ++++++++++----- 8 files changed, 605 insertions(+), 321 deletions(-) diff --git a/docs/_pipeline/templates/getting-started.md.dt b/docs/_pipeline/templates/getting-started.md.dt index d1d9f33c8..ac51d66c1 100644 --- a/docs/_pipeline/templates/getting-started.md.dt +++ b/docs/_pipeline/templates/getting-started.md.dt @@ -31,48 +31,65 @@ the rest of the docset elaborates. > **Public preview package available.** Reactor ships `Microsoft.UI.Reactor` -> `{{reactorVersion}}` on NuGet.org. The project template package is still -> installed from source for now; `bootstrap.ps1` installs `mur`, packs/registers -> the local `reactorapp` template, and stamps generated apps to reference the -> public preview package by default. Broader signed distribution is tracked in +> `{{reactorVersion}}` on NuGet.org, and the project templates ship in the +> official Windows App SDK `dotnet new` pack +> (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`) — so `dotnet new reactor` +> works from a plain .NET SDK install, no source checkout required. +> `bootstrap.ps1` is for *contributors*: it installs `mur`, packs source-built +> framework snapshots, and registers those same templates. Broader signed +> distribution is tracked in > [spec 022](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/specs/022-packaging-and-distribution.md). Reactor is a declarative UI framework for building native Windows apps in pure C#. No XAML, no data binding, no view models. You describe your UI as a function of state and Reactor keeps the screen in sync. -## Setup (one-time) +## Setup + +If you just want to build an app, install the template pack and go — you do not +need to clone this repo: ```powershell -git clone https://github.com/microsoft/microsoft-ui-reactor.git -cd microsoft-ui-reactor -./bootstrap.ps1 +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new reactor -n MyApp +cd MyApp +dotnet run ``` -That's it. `bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global -install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs -`mur pack-local` to produce local source-built framework snapshots and the -matching `ProjectTemplates` nupkg, registers the `dotnet new reactorapp` -template, and drops the Reactor agent plugin under `~/.claude/plugins/reactor` -(symlink when allowed, copy otherwise). Apps created from that template reference -`Microsoft.UI.Reactor` version `{{reactorVersion}}` from NuGet.org by default. +That gives you four starting points — `reactor` (blank), `reactor-mvu` +(Model-View-Update via `UseReducer`), `reactor-navview` (`NavigationView` shell) +and `reactor-tabview` (`TabView` shell). + +Scaffolded apps are **packaged** (single-project MSIX), so `dotnet run` +registers a loose-layout package and launches the app with full package +identity — the same thing F5 does in Visual Studio. That requires **Developer +Mode** (Settings → System → For developers). See [Packaging](packaging.md) for +the unpackaged alternative. + +## Contributor setup (one-time) -When it finishes you can immediately run: +Working *on* Reactor rather than with it? Clone and bootstrap: ```powershell -dotnet new reactorapp -n MyApp -cd MyApp -dotnet run +git clone https://github.com/microsoft/microsoft-ui-reactor.git +cd microsoft-ui-reactor +./bootstrap.ps1 ``` +`bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global +install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs +`mur pack-local` to produce local source-built framework snapshots, installs the +Windows App SDK template pack via `mur templates install`, +and drops the Reactor agent plugin under `~/.claude/plugins/reactor` +(symlink when allowed, copy otherwise). + ### After `git pull` -The source checkout changes — your local template package, CLI, plugin, and -optional source-built framework snapshots do not, unless you repack them. Two -options: +The source checkout changes — your local framework snapshots, CLI and plugin +do not, unless you repack them. Two options: ```powershell -mur upgrade # repacks the framework + templates and refreshes plugin +mur upgrade # repacks the framework and refreshes templates + plugin ./bootstrap.ps1 # same, plus updates the `mur` global tool itself ``` @@ -86,17 +103,16 @@ mur doctor ``` Lists every dependency the rest of this guide assumes — .NET 10+ SDK, `mur` on -PATH, current `local-nupkgs/` developer feed, the `reactorapp` template +PATH, current `local-nupkgs/` developer feed, the `dotnet new reactor` template registration, and the optional Claude plugin. Each line is PASS / WARN / FAIL with a one-line remediation for anything broken. > **What this gets you.** A globally-resolvable `mur` (via `~/.dotnet/tools`), -> a locally installed `reactorapp` template that references -> ` Version="{{reactorVersion}}" />`, a local NuGet feed at `/local-nupkgs/` +> the `dotnet new reactor` templates, a local NuGet feed at +> `/local-nupkgs/` > for source-built smoke tests, and an agent plugin so AI assistants generate > against the real factories (`mur --skill` / `mur --api` print the same -> content). Run `mur upgrade` whenever you pull new template, CLI, plugin, or +> content). Run `mur upgrade` whenever you pull new CLI, plugin, or > framework changes. > **Only need the framework package?** Reference the published @@ -119,8 +135,8 @@ anything goes wrong. | 2 | `git clone` + `cd` | Local source checkout | | 3 | `dotnet pack src/Reactor.Cli` | `Microsoft.UI.Reactor.Cli..nupkg` in `local-nupkgs/` | | 4 | `dotnet tool install -g` | `mur` resolvable cross-shell from `~/.dotnet/tools` | -| 5 | `mur pack-local` | Source-built framework snapshots plus a local `ProjectTemplates` nupkg; generated apps default to the public Reactor preview | -| 6 | `dotnet new uninstall` + `install` | `dotnet new reactorapp` template registered | +| 5 | `mur pack-local` | Source-built framework snapshots in `local-nupkgs/` | +| 6 | `dotnet new install` | `dotnet new reactor` templates registered | | 7 | Symlink/copy `plugins/reactor` | Reactor agent kit under `~/.claude/plugins/reactor` (optional) | | 8 | `mur doctor` | Verification that 1–7 all stuck | @@ -180,18 +196,16 @@ $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" New PowerShell windows pick up the user-PATH change on their own. -**5. Pack local framework snapshots and project templates.** This produces the -source-built `0.0.0-local` framework nupkgs for smoke tests plus the local -`ProjectTemplates` nupkg that installs `dotnet new reactorapp`. The template's -normal default references the public `Microsoft.UI.Reactor` `{{reactorVersion}}` -package. +**5. Pack local framework snapshots.** This produces the source-built +`0.0.0-local` framework nupkgs so recipes and smoke tests in this clone resolve +your working tree instead of the published package. ```powershell mur pack-local # Produces: # local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg # local-nupkgs/Microsoft.UI.Reactor.Advanced.0.0.0-local.nupkg -# local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +# local-nupkgs/Microsoft.UI.Reactor.Devtools.0.0.0-local.nupkg ``` If you'd rather not depend on the freshly-installed `mur`, you can invoke @@ -202,15 +216,17 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` -c Release "-p:Platform=$hostArch" -- pack-local ``` -**6. Install the `dotnet new reactorapp` template.** The template engine -caches by package id, so a same-version repack can lose to the cached copy. -Always uninstall first. +**6. Install the `dotnet new reactor` templates.** These come from the Windows +App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates 2>$null -dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates ``` +`mur templates install` does the same thing but resolves the newest published +version first — including prereleases, which a bare `dotnet new install` cannot +reach. `mur templates status` reports whether `dotnet new reactor` is available. + **7. (Optional) Install the Reactor agent plugin.** If you use Claude Code or another agent and want it to author Reactor code with the right factories, drop the in-repo plugin folder into your agent's plugin path. A @@ -257,43 +273,41 @@ the install must happen from a shell that isn't already running `mur`). > upgrade verb. -The core framework package is public, but the `reactorapp` project-template -package is still source-installed. If `dotnet new reactorapp` is missing, run -`bootstrap.ps1` (or `mur upgrade` from an already bootstrapped checkout) to -repack and reinstall `Microsoft.UI.Reactor.ProjectTemplates` from -`local-nupkgs/`. The template installer caches by package id, so a same-version -repack can lose to the cached copy — `mur upgrade` handles this by running -`dotnet new uninstall` first. +The Reactor templates ship in the Windows App SDK `dotnet new` pack +(`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` +needs no source checkout. If it's missing, run +`dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — or +`mur templates install`, which additionally resolves prerelease versions that a +bare `dotnet new install` cannot reach. ## Creating a Project -With the template installed, scaffold a new app from anywhere on disk: +With the templates installed, scaffold a new app from anywhere on disk: ```powershell -dotnet new reactorapp -n MyApp +dotnet new reactor -n MyApp cd MyApp dotnet run ``` The template wires up the `Microsoft.UI.Reactor` package reference, the -WinUI 3 target framework, and a working `App.cs` that mounts a single -Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# -file. +WinUI 3 target framework, MSIX packaging, and a working `App.cs` that mounts a +single Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# +file. Swap `reactor` for `reactor-mvu`, `reactor-navview` or `reactor-tabview` +to start from a richer shell. -By default that package reference is -``. For local framework smoke tests, generate with -`dotnet new reactorapp -n MyLocalApp --MSUIReactorVersion 0.0.0-local` and run +`dotnet new reactor -n MyLocalApp --reactor-version 0.0.0-local` and run from inside the source checkout or another folder that has the local feed configured. > **Why a custom template?** A `dotnet new console` does not produce a WinUI > app — it builds a console target with no UI thread, no `OutputType=WinExe`, -> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactorapp` -> sets all of those plus the Reactor package reference and a backdrop-aware -> root component, so you get a window on first `dotnet run` instead of a -> console-host stub. +> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactor` +> sets all of those plus the Reactor package reference, MSIX packaging and a +> backdrop-aware root component, so you get a window on first `dotnet run` +> instead of a console-host stub. ## Your First App @@ -446,7 +460,7 @@ side effects, `UseRef` for non-rendering bookkeeping). The dev menu needs **two** independent signals, and neither one is `#if DEBUG`. First the build-time capability — the `Reactor.DevtoolsSupport` -feature switch, which `dotnet new reactorapp` already sets in Debug +feature switch, which `dotnet new reactor` already sets in Debug configurations along with the `Microsoft.UI.Reactor.Devtools` package. Second a session opt-in on the command line: diff --git a/docs/_pipeline/templates/packaging.md.dt b/docs/_pipeline/templates/packaging.md.dt index 5a5f9d889..e76de27bc 100644 --- a/docs/_pipeline/templates/packaging.md.dt +++ b/docs/_pipeline/templates/packaging.md.dt @@ -10,7 +10,7 @@ goal: | identifier, and what does and doesn't work under Native AOT given Reactor's reflection-heavy DataGrid / devtools paths. Solid tier — CSPROJ-driven snippets pulled from real samples + the - `dotnet new reactorapp` template. + `dotnet new reactor` templates. tier: solid --- @@ -19,9 +19,9 @@ tier: solid A Microsoft.UI.Reactor (Reactor) app is a normal WinUI 3 / Windows App SDK executable — `dotnet publish` produces the deployable artifact and the framework itself adds nothing exotic to the project file. What you choose at -publish time is the **shape** of that artifact: an unpackaged folder -(the [`dotnet new reactorapp`](getting-started.md) default), a signed -MSIX, a single-file bundle, or a Native AOT native binary — each +publish time is the **shape** of that artifact: a packaged MSIX +(the [`dotnet new reactor`](getting-started.md) default), an unpackaged +folder, a single-file bundle, or a Native AOT native binary — each combined with a `win-x64` or `win-arm64` runtime identifier. The trade-offs are the same ones any WinUI 3 app faces; the Reactor-specific notes on this page cover what changes when your @@ -46,8 +46,8 @@ folder or an MSIX. The decision is usually distribution-channel-first ## The unpackaged shape -`dotnet new reactorapp` scaffolds an unpackaged WinUI 3 project — the -shape every sample in this repo also uses: +Prefer a zip-and-go folder over an MSIX? Set `WindowsPackageType=None` +on a scaffolded project — the shape every sample in this repo uses: ```xml snippet="source:samples/TodoApp/TodoApp.csproj#unpackaged-shape" ``` @@ -170,8 +170,8 @@ and any `System.Drawing.Common` / `TraceEvent` natives transitively pulled in by Reactor) ship per-RID, which is why the runtime identifier matters even for managed-only Reactor code. The repo's sample apps default to `x64;ARM64`; the -`reactorapp` template uses `x64;ARM64;X86` -(X86 retained for parity with the WinUI 3 templates), but Reactor +`dotnet new reactor` templates use `x86;x64;ARM64` +(x86 retained for parity with the WinUI 3 templates), but Reactor itself is only tested on x64 / ARM64. ## Native AOT @@ -186,14 +186,21 @@ and a runtime identifier: `dotnet publish -c Release -r win-x64` produces a native binary — no `coreclr.dll`, no JIT, ~50 ms cold start versus ~250 ms for the -JIT-based build on the same hardware. The project template gates -the same shape behind a `NativeAot` parameter: +JIT-based build on the same hardware. -```xml snippet="source:tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj#template-shape" +The `dotnet new reactor` templates do **not** enable AOT — they ship +`PublishReadyToRun` + `PublishTrimmed` for non-Debug configurations +instead. To go all the way to AOT, add the properties to the scaffolded +CSPROJ yourself: + +```xml + + true + true + ``` -Pass `dotnet new reactorapp --NativeAot true` to get the AOT-enabled -variant. `InvariantGlobalization=true` is paired with `PublishAot` +`InvariantGlobalization=true` is paired with `PublishAot` because the alternative — shipping the full ICU data — pulls in trim warnings that the AOT analyzer flags as actionable. @@ -302,7 +309,7 @@ in Release too. ## Next Steps - **[Dev Tooling](dev-tooling.md)** — Previous: the inner-loop side of the build pipeline (`mur pack-local`, `dotnet watch`, hot reload). -- **[Getting Started](getting-started.md)** — Where the `dotnet new reactorapp` template that produces the unpackaged shape comes from. +- **[Getting Started](getting-started.md)** — Where the `dotnet new reactor` templates that produce the packaged shape come from. - **[Performance](performance.md)** — When you should reach for AOT (cold-start budgets, startup-perf benchmarks). - **[Perf Instrumentation](perf-instrumentation.md)** — The ETW / EventPipe pipeline that survives AOT publish unchanged. - **[Dev Tooling](dev-tooling.md)** — How the `Reactor.DevtoolsSupport` capability switch combines with `--devtools` activation. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 905a78048..3621793a8 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -19,48 +19,65 @@ the rest of the docset elaborates. > **Public preview package available.** Reactor ships `Microsoft.UI.Reactor` -> `0.1.0-preview.16` on NuGet.org. The project template package is still -> installed from source for now; `bootstrap.ps1` installs `mur`, packs/registers -> the local `reactorapp` template, and stamps generated apps to reference the -> public preview package by default. Broader signed distribution is tracked in +> `0.1.0-preview.16` on NuGet.org, and the project templates ship in the +> official Windows App SDK `dotnet new` pack +> (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`) — so `dotnet new reactor` +> works from a plain .NET SDK install, no source checkout required. +> `bootstrap.ps1` is for *contributors*: it installs `mur`, packs source-built +> framework snapshots, and registers those same templates. Broader signed +> distribution is tracked in > [spec 022](https://github.com/microsoft/microsoft-ui-reactor/blob/main/docs/specs/022-packaging-and-distribution.md). Reactor is a declarative UI framework for building native Windows apps in pure C#. No XAML, no data binding, no view models. You describe your UI as a function of state and Reactor keeps the screen in sync. -## Setup (one-time) +## Setup + +If you just want to build an app, install the template pack and go — you do not +need to clone this repo: ```powershell -git clone https://github.com/microsoft/microsoft-ui-reactor.git -cd microsoft-ui-reactor -./bootstrap.ps1 +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new reactor -n MyApp +cd MyApp +dotnet run ``` -That's it. `bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global -install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs -`mur pack-local` to produce local source-built framework snapshots and the -matching `ProjectTemplates` nupkg, registers the `dotnet new reactorapp` -template, and drops the Reactor agent plugin under `~/.claude/plugins/reactor` -(symlink when allowed, copy otherwise). Apps created from that template reference -`Microsoft.UI.Reactor` version `0.1.0-preview.16` from NuGet.org by default. +That gives you four starting points — `reactor` (blank), `reactor-mvu` +(Model-View-Update via `UseReducer`), `reactor-navview` (`NavigationView` shell) +and `reactor-tabview` (`TabView` shell). + +Scaffolded apps are **packaged** (single-project MSIX), so `dotnet run` +registers a loose-layout package and launches the app with full package +identity — the same thing F5 does in Visual Studio. That requires **Developer +Mode** (Settings → System → For developers). See [Packaging](packaging.md) for +the unpackaged alternative. + +## Contributor setup (one-time) -When it finishes you can immediately run: +Working *on* Reactor rather than with it? Clone and bootstrap: ```powershell -dotnet new reactorapp -n MyApp -cd MyApp -dotnet run +git clone https://github.com/microsoft/microsoft-ui-reactor.git +cd microsoft-ui-reactor +./bootstrap.ps1 ``` +`bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global +install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs +`mur pack-local` to produce local source-built framework snapshots, installs the +Windows App SDK template pack via `mur templates install`, +and drops the Reactor agent plugin under `~/.claude/plugins/reactor` +(symlink when allowed, copy otherwise). + ### After `git pull` -The source checkout changes — your local template package, CLI, plugin, and -optional source-built framework snapshots do not, unless you repack them. Two -options: +The source checkout changes — your local framework snapshots, CLI and plugin +do not, unless you repack them. Two options: ```powershell -mur upgrade # repacks the framework + templates and refreshes plugin +mur upgrade # repacks the framework and refreshes templates + plugin ./bootstrap.ps1 # same, plus updates the `mur` global tool itself ``` @@ -74,17 +91,16 @@ mur doctor ``` Lists every dependency the rest of this guide assumes — .NET 10+ SDK, `mur` on -PATH, current `local-nupkgs/` developer feed, the `reactorapp` template +PATH, current `local-nupkgs/` developer feed, the `dotnet new reactor` template registration, and the optional Claude plugin. Each line is PASS / WARN / FAIL with a one-line remediation for anything broken. > **What this gets you.** A globally-resolvable `mur` (via `~/.dotnet/tools`), -> a locally installed `reactorapp` template that references -> ` Version="0.1.0-preview.16" />`, a local NuGet feed at `/local-nupkgs/` +> the `dotnet new reactor` templates, a local NuGet feed at +> `/local-nupkgs/` > for source-built smoke tests, and an agent plugin so AI assistants generate > against the real factories (`mur --skill` / `mur --api` print the same -> content). Run `mur upgrade` whenever you pull new template, CLI, plugin, or +> content). Run `mur upgrade` whenever you pull new CLI, plugin, or > framework changes. > **Only need the framework package?** Reference the published @@ -107,8 +123,8 @@ anything goes wrong. | 2 | `git clone` + `cd` | Local source checkout | | 3 | `dotnet pack src/Reactor.Cli` | `Microsoft.UI.Reactor.Cli..nupkg` in `local-nupkgs/` | | 4 | `dotnet tool install -g` | `mur` resolvable cross-shell from `~/.dotnet/tools` | -| 5 | `mur pack-local` | Source-built framework snapshots plus a local `ProjectTemplates` nupkg; generated apps default to the public Reactor preview | -| 6 | `dotnet new uninstall` + `install` | `dotnet new reactorapp` template registered | +| 5 | `mur pack-local` | Source-built framework snapshots in `local-nupkgs/` | +| 6 | `dotnet new install` | `dotnet new reactor` templates registered | | 7 | Symlink/copy `plugins/reactor` | Reactor agent kit under `~/.claude/plugins/reactor` (optional) | | 8 | `mur doctor` | Verification that 1–7 all stuck | @@ -168,18 +184,16 @@ $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" New PowerShell windows pick up the user-PATH change on their own. -**5. Pack local framework snapshots and project templates.** This produces the -source-built `0.0.0-local` framework nupkgs for smoke tests plus the local -`ProjectTemplates` nupkg that installs `dotnet new reactorapp`. The template's -normal default references the public `Microsoft.UI.Reactor` `0.1.0-preview.16` -package. +**5. Pack local framework snapshots.** This produces the source-built +`0.0.0-local` framework nupkgs so recipes and smoke tests in this clone resolve +your working tree instead of the published package. ```powershell mur pack-local # Produces: # local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg # local-nupkgs/Microsoft.UI.Reactor.Advanced.0.0.0-local.nupkg -# local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +# local-nupkgs/Microsoft.UI.Reactor.Devtools.0.0.0-local.nupkg ``` If you'd rather not depend on the freshly-installed `mur`, you can invoke @@ -190,15 +204,17 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` -c Release "-p:Platform=$hostArch" -- pack-local ``` -**6. Install the `dotnet new reactorapp` template.** The template engine -caches by package id, so a same-version repack can lose to the cached copy. -Always uninstall first. +**6. Install the `dotnet new reactor` templates.** These come from the Windows +App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates 2>$null -dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates ``` +`mur templates install` does the same thing but resolves the newest published +version first — including prereleases, which a bare `dotnet new install` cannot +reach. `mur templates status` reports whether `dotnet new reactor` is available. + **7. (Optional) Install the Reactor agent plugin.** If you use Claude Code or another agent and want it to author Reactor code with the right factories, drop the in-repo plugin folder into your agent's plugin path. A @@ -244,42 +260,40 @@ the install must happen from a shell that isn't already running `mur`). > with no arch-aware PATH munging, and `dotnet tool update -g` becomes the > upgrade verb. -> **Caveat:** The core framework package is public, but the `reactorapp` project-template -> package is still source-installed. If `dotnet new reactorapp` is missing, run -> `bootstrap.ps1` (or `mur upgrade` from an already bootstrapped checkout) to -> repack and reinstall `Microsoft.UI.Reactor.ProjectTemplates` from -> `local-nupkgs/`. The template installer caches by package id, so a same-version -> repack can lose to the cached copy — `mur upgrade` handles this by running -> `dotnet new uninstall` first. +> **Caveat:** The Reactor templates ship in the Windows App SDK `dotnet new` pack +> (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` +> needs no source checkout. If it's missing, run +> `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — or +> `mur templates install`, which additionally resolves prerelease versions that a +> bare `dotnet new install` cannot reach. ## Creating a Project -With the template installed, scaffold a new app from anywhere on disk: +With the templates installed, scaffold a new app from anywhere on disk: ```powershell -dotnet new reactorapp -n MyApp +dotnet new reactor -n MyApp cd MyApp dotnet run ``` The template wires up the `Microsoft.UI.Reactor` package reference, the -WinUI 3 target framework, and a working `App.cs` that mounts a single -Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# -file. +WinUI 3 target framework, MSIX packaging, and a working `App.cs` that mounts a +single Reactor component. No `App.xaml`, no `MainWindow.xaml.cs` — just one C# +file. Swap `reactor` for `reactor-mvu`, `reactor-navview` or `reactor-tabview` +to start from a richer shell. -By default that package reference is -``. For local framework smoke tests, generate with -`dotnet new reactorapp -n MyLocalApp --MSUIReactorVersion 0.0.0-local` and run +`dotnet new reactor -n MyLocalApp --reactor-version 0.0.0-local` and run from inside the source checkout or another folder that has the local feed configured. > **Why a custom template?** A `dotnet new console` does not produce a WinUI > app — it builds a console target with no UI thread, no `OutputType=WinExe`, -> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactorapp` -> sets all of those plus the Reactor package reference and a backdrop-aware -> root component, so you get a window on first `dotnet run` instead of a -> console-host stub. +> no WindowsAppSDK reference, and no `[STAThread]` entry point. `reactor` +> sets all of those plus the Reactor package reference, MSIX packaging and a +> backdrop-aware root component, so you get a window on first `dotnet run` +> instead of a console-host stub. ## Your First App @@ -735,7 +749,7 @@ side effects, `UseRef` for non-rendering bookkeeping). The dev menu needs **two** independent signals, and neither one is `#if DEBUG`. First the build-time capability — the `Reactor.DevtoolsSupport` -feature switch, which `dotnet new reactorapp` already sets in Debug +feature switch, which `dotnet new reactor` already sets in Debug configurations along with the `Microsoft.UI.Reactor.Devtools` package. Second a session opt-in on the command line: diff --git a/docs/guide/packaging.md b/docs/guide/packaging.md index 63d225684..339a51195 100644 --- a/docs/guide/packaging.md +++ b/docs/guide/packaging.md @@ -4,9 +4,9 @@ A Microsoft.UI.Reactor (Reactor) app is a normal WinUI 3 / Windows App SDK executable — `dotnet publish` produces the deployable artifact and the framework itself adds nothing exotic to the project file. What you choose at -publish time is the **shape** of that artifact: an unpackaged folder -(the [`dotnet new reactorapp`](getting-started.md) default), a signed -MSIX, a single-file bundle, or a Native AOT native binary — each +publish time is the **shape** of that artifact: a packaged MSIX +(the [`dotnet new reactor`](getting-started.md) default), an unpackaged +folder, a single-file bundle, or a Native AOT native binary — each combined with a `win-x64` or `win-arm64` runtime identifier. The trade-offs are the same ones any WinUI 3 app faces; the Reactor-specific notes on this page cover what changes when your @@ -31,8 +31,8 @@ folder or an MSIX. The decision is usually distribution-channel-first ## The unpackaged shape -`dotnet new reactorapp` scaffolds an unpackaged WinUI 3 project — the -shape every sample in this repo also uses: +Prefer a zip-and-go folder over an MSIX? Set `WindowsPackageType=None` +on a scaffolded project — the shape every sample in this repo uses: ```xml @@ -164,8 +164,8 @@ and any `System.Drawing.Common` / `TraceEvent` natives transitively pulled in by Reactor) ship per-RID, which is why the runtime identifier matters even for managed-only Reactor code. The repo's sample apps default to `x64;ARM64`; the -`reactorapp` template uses `x64;ARM64;X86` -(X86 retained for parity with the WinUI 3 templates), but Reactor +`dotnet new reactor` templates use `x86;x64;ARM64` +(x86 retained for parity with the WinUI 3 templates), but Reactor itself is only tested on x64 / ARM64. ## Native AOT @@ -192,64 +192,21 @@ and a runtime identifier: `dotnet publish -c Release -r win-x64` produces a native binary — no `coreclr.dll`, no JIT, ~50 ms cold start versus ~250 ms for the -JIT-based build on the same hardware. The project template gates -the same shape behind a `NativeAot` parameter: +JIT-based build on the same hardware. + +The `dotnet new reactor` templates do **not** enable AOT — they ship +`PublishReadyToRun` + `PublishTrimmed` for non-Debug configurations +instead. To go all the way to AOT, add the properties to the scaffolded +CSPROJ yourself: ```xml - - WinExe - net10.0-windows10.0.22621.0 - TargetFrameworkOverride-windows10.0.22621.0 - - x64;ARM64;X86 - true - None - - true - 10.0.17763.0 - 10.0.17763.0 - enable - - Assets\AppIcon.ico - - $(NETCoreSdkPortableRuntimeIdentifier) - - true - true - + + true + true ``` -Pass `dotnet new reactorapp --NativeAot true` to get the AOT-enabled -variant. `InvariantGlobalization=true` is paired with `PublishAot` +`InvariantGlobalization=true` is paired with `PublishAot` because the alternative — shipping the full ICU data — pulls in trim warnings that the AOT analyzer flags as actionable. @@ -356,7 +313,7 @@ in Release too. ## Next Steps - **[Dev Tooling](dev-tooling.md)** — Previous: the inner-loop side of the build pipeline (`mur pack-local`, `dotnet watch`, hot reload). -- **[Getting Started](getting-started.md)** — Where the `dotnet new reactorapp` template that produces the unpackaged shape comes from. +- **[Getting Started](getting-started.md)** — Where the `dotnet new reactor` templates that produce the packaged shape come from. - **[Performance](performance.md)** — When you should reach for AOT (cold-start budgets, startup-perf benchmarks). - **[Perf Instrumentation](perf-instrumentation.md)** — The ETW / EventPipe pipeline that survives AOT publish unchanged. - **[Dev Tooling](dev-tooling.md)** — How the `Reactor.DevtoolsSupport` capability switch combines with `--devtools` activation. diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index b28200ff8..843791814 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -48,8 +48,23 @@ public static int Run(string[] args) static int Install(string[] args) { - var source = ParseFlag(args, "--source"); - var version = ParseFlag(args, "--version"); + // Help must never mutate the machine: `mur templates install --help` + // previously fell straight through to a real install. + if (args.Any(a => a is "--help" or "-h")) + { + ShowInstallHelp(); + return 0; + } + + // Reject anything we don't understand rather than silently ignoring it — + // a typo like `--sorce ./pkgs` would otherwise install from the wrong place. + if (!TryParseInstallArgs(args, out var source, out var version, out var error)) + { + Console.Error.WriteLine($"mur templates install: {error}"); + Console.Error.WriteLine(); + ShowInstallHelp(); + return 1; + } Console.WriteLine($"Installing {WinAppSdkTemplates.PackageId} (`dotnet new {WinAppSdkTemplates.BlankShortName}`)"); @@ -76,21 +91,7 @@ static int Install(string[] args) // Don't claim an install happened when the existing pack was simply kept // or was already current — the user needs to know whether anything moved. Console.WriteLine(); - switch (outcome) - { - case WinAppSdkTemplates.InstallOutcome.KeptExisting: - Console.WriteLine("Kept the existing install (could not resolve a published version)."); - break; - case WinAppSdkTemplates.InstallOutcome.AlreadyCurrent: - Console.WriteLine("Already up to date."); - break; - case WinAppSdkTemplates.InstallOutcome.Updated: - Console.WriteLine("Updated."); - break; - default: - Console.WriteLine("Installed."); - break; - } + Console.WriteLine(DescribeOutcome(outcome)); Console.WriteLine("Scaffold an app with:"); foreach (var name in WinAppSdkTemplates.ShortNames) @@ -130,6 +131,21 @@ static int Status() return 1; } + ///

+ /// Human-readable summary of what an install actually did. Split out (and + /// internal) so the mapping is testable: the bug this guards is reporting + /// "Installed." when the command deliberately kept an existing pack. + /// + internal static string DescribeOutcome(WinAppSdkTemplates.InstallOutcome outcome) => outcome switch + { + WinAppSdkTemplates.InstallOutcome.KeptExisting => + "Kept the existing install (could not resolve a published version).", + WinAppSdkTemplates.InstallOutcome.AlreadyCurrent => "Already up to date.", + WinAppSdkTemplates.InstallOutcome.Updated => "Updated.", + WinAppSdkTemplates.InstallOutcome.Installed => "Installed.", + _ => "Install failed.", + }; + static void ShowHelp() { Console.WriteLine("Usage: mur templates [options]"); @@ -141,18 +157,57 @@ static void ShowHelp() Console.WriteLine(" install Install or reinstall the template pack"); Console.WriteLine(" status Report whether the pack is registered"); Console.WriteLine(); - Console.WriteLine("Options (install):"); - Console.WriteLine(" --source Extra NuGet source; use a folder of nupkgs to test an unpublished build"); - Console.WriteLine(" --version Pin an explicit version instead of resolving the newest published one"); + Console.WriteLine("Run `mur templates install --help` for install options."); } - static string? ParseFlag(string[] args, string name) + static void ShowInstallHelp() { - for (var i = 0; i < args.Length - 1; i++) + Console.WriteLine("Usage: mur templates install [--source ] [--version ]"); + Console.WriteLine(); + Console.WriteLine($"Installs {WinAppSdkTemplates.PackageId}. With no options it resolves the"); + Console.WriteLine("newest published version (newest stable, else newest prerelease)."); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --source Folder of .nupkg files, to install an unpublished build."); + Console.WriteLine(" A feed URL also works but cannot be enumerated, so it"); + Console.WriteLine(" requires --version."); + Console.WriteLine(" --version Pin an explicit version instead of resolving."); + Console.WriteLine(" --help, -h Show this help."); + } + + /// + /// Strict argv parsing for `install`. Rejects unknown flags, bare positional + /// arguments, and flags with a missing value, so a typo cannot silently change + /// what gets installed. + /// + static bool TryParseInstallArgs(string[] args, out string? source, out string? version, out string? error) + { + source = null; + version = null; + error = null; + + for (var i = 0; i < args.Length; i++) { - if (string.Equals(args[i], name, StringComparison.Ordinal)) - return args[i + 1]; + var arg = args[i]; + switch (arg) + { + case "--source": + case "--version": + if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + error = $"'{arg}' requires a value."; + return false; + } + if (arg == "--source") source = args[++i]; + else version = args[++i]; + break; + default: + error = arg.StartsWith("-", StringComparison.Ordinal) + ? $"unknown option '{arg}'." + : $"unexpected argument '{arg}'."; + return false; + } } - return null; + return true; } } diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 2ee342c26..b24d2396d 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -124,11 +124,21 @@ internal static bool InterpretTemplateListOutput(string output) public static string? GetInstalledVersion() { var output = RunCapture("new", "uninstall"); - if (output is null) return null; + return output is null ? null : InterpretInstalledVersionOutput(output); + } + /// + /// Pulls this pack's version out of `dotnet new uninstall` output. Split out + /// (and internal) so the parser is testable against captured CLI text without + /// invoking the template engine. + /// + internal static string? InterpretInstalledVersionOutput(string output) + { // The listing indents each package id, then its metadata: // Microsoft.WindowsAppSDK.WinUI.CSharp.Templates // Version: 0.0.6-alpha + // Several packages can be listed, so match the id line exactly rather than + // by substring — other ids legitimately contain this one as a prefix. var lines = output.Replace("\r\n", "\n").Split('\n'); for (var i = 0; i < lines.Length; i++) { @@ -161,11 +171,89 @@ public enum InstallOutcome Failed, } + /// What an call should do, decided from inputs alone. + /// + /// Split out from so the decision table is unit-testable + /// without shelling out to the template engine or touching the machine. The + /// destructive case is : it is the only + /// path that passes `--force`, which uninstalls before downloading. + /// + internal enum InstallAction + { + /// Leave the existing install alone (nothing resolvable to move to). + KeepExisting, + /// Install without `--force` — nothing is installed, so there is nothing to lose. + PlainInstall, + /// Replace an existing install with a version known to exist. + ForcedReplace, + /// The resolved target is already installed; do nothing. + AlreadyCurrent, + /// + /// An explicit version was pinned but could not be confirmed to exist. Refuse + /// rather than `--force`, which would uninstall the working pack and then fail. + /// + RefuseUnverifiedPin, + } + + /// + /// Pure decision table for . + /// + /// Currently installed version, or null. + /// Version we want, or null when none could be resolved. + /// + /// True only when was confirmed present in the feed or + /// folder. A pinned version that could not be confirmed must never be forced. + /// + /// True when an extra NuGet source was supplied. + internal static InstallAction PlanInstall(string? installed, string? target, bool targetExists, bool hasSource) + { + if (target is null) + return installed is not null ? InstallAction.KeepExisting : InstallAction.PlainInstall; + + // An explicit source means "get it from here even if the id/version matches", + // so don't short-circuit on an equal version string in that case. + if (installed is not null && !hasSource && + string.Equals(installed, target, StringComparison.OrdinalIgnoreCase)) + return InstallAction.AlreadyCurrent; + + // Nothing installed: a plain install cannot destroy anything. + if (installed is null) + return InstallAction.PlainInstall; + + // Replacing an existing install requires --force, which uninstalls first. + // Only take that path for a version we know is actually there. + return targetExists ? InstallAction.ForcedReplace : InstallAction.RefuseUnverifiedPin; + } + + /// + /// Masks credentials in a NuGet source before it is echoed. Feed URLs can carry a + /// PAT in the user-info or query segment, and this command line is printed to the + /// console and into CI logs. + /// + internal static string RedactSource(string source) + { + if (string.IsNullOrWhiteSpace(source)) return source; + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || uri.IsFile) + return source; // local folder path — nothing secret in it + + var builder = new UriBuilder(uri) + { + UserName = string.IsNullOrEmpty(uri.UserInfo) ? string.Empty : "***", + Password = string.Empty, + Query = string.IsNullOrEmpty(uri.Query) ? string.Empty : "***", + }; + return builder.Uri.ToString(); + } + /// /// Installs (or updates) the template pack. /// /// Working directory for the `dotnet` process. - /// Extra NuGet source — a local folder holding the nupkg, or a feed URL. This is how an unpublished build gets tested. + /// + /// Extra NuGet source. A local folder holding the nupkg is fully supported. A feed + /// URL is passed to `dotnet new install --add-source`, but version *resolution* only + /// reads local folders, so a URL source requires an explicit . + /// /// Explicit version to pin. When omitted the newest published version is resolved. /// /// Returns what actually happened rather than a bare exit code: "kept the @@ -174,102 +262,160 @@ public enum InstallOutcome /// public static InstallOutcome Install(string workingDirectory, string? source = null, string? version = null) { + var hasSource = !string.IsNullOrWhiteSpace(source); + var pinned = !string.IsNullOrWhiteSpace(version); + + // A template pack generates code, so refuse to fetch one over plaintext + // http:// — a MITM could swap the scaffold. Local folders and https are fine. + if (hasSource && + Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) && + !sourceUri.IsFile && + !string.Equals(sourceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine( + $" error: refusing to install a template package from an insecure source " + + $"('{sourceUri.Scheme}'). Use https:// or a local folder."); + return InstallOutcome.Failed; + } + + // A URL source cannot be enumerated here (only folders and the public index + // are), so without a pin we would silently resolve a version from nuget.org + // and then install it from the user's feed — a different package than asked for. + if (hasSource && !pinned && !Directory.Exists(source)) + { + Console.Error.WriteLine( + $" error: --source '{RedactSource(source!)}' is not a local folder, and versions cannot be " + + $"enumerated from a feed URL here. Pass an explicit --version to install from it."); + return InstallOutcome.Failed; + } + var installed = GetInstalledVersion(); - var target = string.IsNullOrWhiteSpace(version) ? ResolveLatestVersion(source) : version!.Trim(); - // `dotnet new install --force` uninstalls the existing package *before* - // downloading the replacement, so a failed install leaves the machine - // with no templates at all. Never take that path unless we have a - // concrete version we know exists (resolved from the live NuGet index or - // from a nupkg filename on disk). Without one, keep what's installed. - if (target is null) + string? target; + bool targetExists; + if (pinned) { - if (installed is not null) - { + target = version!.Trim(); + // Confirm the pin before considering --force. Null means "couldn't tell", + // which is treated as unverified — never destructive on a maybe. + var available = ResolveAvailableVersions(source); + targetExists = available is not null && + available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); + } + else + { + target = ResolveLatestVersion(source); + // A resolved target came out of the feed listing, so it exists by construction. + targetExists = target is not null; + } + + switch (PlanInstall(installed, target, targetExists, hasSource)) + { + case InstallAction.KeepExisting: Console.Error.WriteLine( $" warning: could not resolve a published version of {PackageId}; " + $"keeping the installed {installed}. Re-run with network access, or pass an explicit version."); return InstallOutcome.KeptExisting; - } - // Nothing installed and nothing resolved — try a plain install (no - // --force, so there is nothing to lose) and let NuGet report why. - Console.WriteLine($" dotnet new install {PackageId}"); - return Run(workingDirectory, "new", "install", PackageId) == 0 - ? InstallOutcome.Installed - : InstallOutcome.Failed; - } + case InstallAction.AlreadyCurrent: + Console.WriteLine($" Already installed: {PackageId} {installed}"); + return InstallOutcome.AlreadyCurrent; - if (installed is not null && - string.Equals(installed, target, StringComparison.OrdinalIgnoreCase) && - string.IsNullOrWhiteSpace(source)) - { - Console.WriteLine($" Already installed: {PackageId} {installed}"); - return InstallOutcome.AlreadyCurrent; + case InstallAction.RefuseUnverifiedPin: + Console.Error.WriteLine( + $" error: {PackageId} {target} could not be found in the configured sources, and " + + $"replacing an install requires `--force`, which uninstalls the current {installed} " + + $"before downloading. Refusing, so your working install survives. " + + $"Check the version, or pass --source with the folder that has it."); + return InstallOutcome.Failed; + + case InstallAction.PlainInstall: + return RunInstall(workingDirectory, target, source, force: false, installed); + + default: // ForcedReplace + return RunInstall(workingDirectory, target, source, force: true, installed); } + } + static InstallOutcome RunInstall(string workingDirectory, string? target, string? source, bool force, string? installed) + { Console.WriteLine(installed is null - ? $" Installing {PackageId} {target}" + ? $" Installing {PackageId} {target ?? "(latest stable)"}" : $" Updating {PackageId} {installed} → {target}"); // `::` is `dotnet new install`'s explicit-version syntax and // the only way to reach a prerelease — a bare id resolves stable-only. - var args = new List { "new", "install", $"{PackageId}::{target}" }; - - // --force is required to replace an existing install; skip it otherwise - // so a first-time install can never uninstall anything. - if (installed is not null) - args.Add("--force"); - + var spec = target is null ? PackageId : $"{PackageId}::{target}"; + var args = new List { "new", "install", spec }; + if (force) args.Add("--force"); if (!string.IsNullOrWhiteSpace(source)) { args.Add("--add-source"); args.Add(source!); } - Console.WriteLine($" dotnet {string.Join(' ', args)}"); + // Echo with the source redacted — a feed URL can carry a PAT, and this line + // lands in console output and CI logs. + var echo = args.Select(a => string.Equals(a, source, StringComparison.Ordinal) ? RedactSource(a) : a); + Console.WriteLine($" dotnet {string.Join(' ', echo)}"); + var rc = Run(workingDirectory, args.ToArray()); if (rc != 0) { - if (installed is not null) + if (force && installed is not null) { Console.Error.WriteLine( $" warning: the update failed and `dotnet new install --force` removes the old package first, " + $"so {PackageId} may no longer be installed. Restore it with: " + $"dotnet new install {PackageId}::{installed}"); } + Console.Error.WriteLine( + " note: `dotnet new install` does not use the NuGet credential provider, so an authenticated " + + "feed reports \"the package does not exist\". Restore the package first, then pass the cached " + + ".nupkg folder to --source."); return InstallOutcome.Failed; } return installed is null ? InstallOutcome.Installed : InstallOutcome.Updated; } /// - /// Newest published version of the pack — the newest stable when one exists, - /// otherwise the newest prerelease. Returns null when nothing could be - /// resolved (offline, unreachable feed, empty folder). + /// Every version the configured source offers, or null when the listing could + /// not be obtained (offline, unreachable feed). Null means "couldn't tell" and + /// must never be read as "the version is absent". /// - public static string? ResolveLatestVersion(string? source = null) + internal static IReadOnlyList? ResolveAvailableVersions(string? source = null) { // A local folder source is the unpublished-build test path: read the // versions straight off the nupkg filenames rather than hitting NuGet. if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) - return SelectPreferStable(EnumerateLocalVersions(source!)); + return EnumerateLocalVersions(source!); try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; var json = http.GetStringAsync(FlatContainerIndexUrl).GetAwaiter().GetResult(); - return SelectPreferStable(PackLocalCommand.ParseFlatContainerVersions(json)); + return PackLocalCommand.ParseFlatContainerVersions(json); } catch (Exception ex) { Console.Error.WriteLine( $" warning: could not query NuGet for {PackageId} versions " + - $"({ex.GetType().Name}: {ex.Message}); falling back to the default resolution."); + $"({ex.GetType().Name}: {ex.Message})."); return null; } } + /// + /// Newest published version of the pack — the newest stable when one exists, + /// otherwise the newest prerelease. Returns null when nothing could be + /// resolved (offline, unreachable feed, empty folder). + /// + public static string? ResolveLatestVersion(string? source = null) + { + var versions = ResolveAvailableVersions(source); + return versions is null ? null : SelectPreferStable(versions); + } + /// /// Highest stable version, or the highest prerelease when no stable exists. /// Split out (and internal) so the preference rule is unit-testable without diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index fc94ffa96..b1ef8f416 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -61,6 +61,14 @@ public static int Run(string[] args) var templateOutcome = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion); if (templateOutcome == WinAppSdkTemplates.InstallOutcome.Failed) { + // Best-effort when it's the routine refresh — a NuGet hiccup shouldn't fail + // the whole upgrade. But if the user explicitly asked for a specific source + // or version, silently returning 0 would report success for work not done. + if (templateSource is not null || templateVersion is not null) + { + Console.Error.WriteLine($"mur upgrade: could not install {WinAppSdkTemplates.PackageId} as requested."); + return 1; + } Console.Error.WriteLine($" Could not install {WinAppSdkTemplates.PackageId}; the rest of the upgrade completed."); } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 6a84f8557..3a89662d8 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -166,28 +166,127 @@ public void ResolveLatestVersion_returns_null_for_an_empty_local_source() } } + // ── Destructive-install decision table ───────────────────────────────── + // + // `dotnet new install --force` uninstalls the existing package BEFORE + // downloading the replacement, so a failed install leaves the machine with no + // templates at all. Observed for real: `--force` with a spec that did not + // resolve uninstalled a working prerelease and then failed with exit 103. + // + // `PlanInstall` is the pure decision that governs when `--force` is used, so + // these drive the real behaviour rather than grepping the source for a string. + + [Theory] + // No target resolved: keep whatever is installed; never force. + [InlineData("0.0.6-alpha", null, false, false, "KeepExisting")] + // Nothing installed and nothing resolved: a plain install can't destroy anything. + [InlineData(null, null, false, false, "PlainInstall")] + // Nothing installed: plain install even for a confirmed target (no --force needed). + [InlineData(null, "0.0.7-alpha", true, false, "PlainInstall")] + // Same version already installed, no source: no-op. + [InlineData("0.0.7-alpha", "0.0.7-alpha", true, false, "AlreadyCurrent")] + // Replacing an install with a CONFIRMED version is the only forced path. + [InlineData("0.0.6-alpha", "0.0.7-alpha", true, false, "ForcedReplace")] + // THE REGRESSION: a pin that could not be confirmed must NOT force. + [InlineData("0.0.6-alpha", "0.0.9-nope", false, false, "RefuseUnverifiedPin")] + // An explicit source means "install from here", so an equal version still installs. + [InlineData("0.0.7-alpha", "0.0.7-alpha", true, true, "ForcedReplace")] + public void PlanInstall_only_forces_for_a_confirmed_target( + string? installed, string? target, bool targetExists, bool hasSource, string expected) + { + var actual = WinAppSdkTemplates.PlanInstall(installed, target, targetExists, hasSource); + Assert.Equal(expected, actual.ToString()); + } + [Fact] - public void Install_never_pairs_force_with_an_unresolved_package_spec() + public void PlanInstall_never_forces_an_unconfirmed_target() { - // Source-level guard. Install() shells out to `dotnet new`, so driving it - // for real would mutate the developer's machine — exactly the damage being - // guarded against. Instead assert the invariant on the source: every - // "--force" must be added on a path that has a concrete version, and the - // bare-id install (the `target is null` fallback) must not add --force. - var (path, text) = ReadCliSource(); - - // The bare-id fallback line — the one that runs when no version resolved. - Assert.Contains("\"new\", \"install\", PackageId)", text.Replace("\r\n", "\n")); - Assert.DoesNotContain("\"new\", \"install\", PackageId, \"--force\"", text); - - // --force must be conditional on something already being installed. - Assert.True( - global::System.Text.RegularExpressions.Regex.IsMatch( - text.Replace("\r\n", "\n"), - @"if \(installed is not null\)\s*\n\s*args\.Add\(""--force""\);"), - $"'{path}' must only add --force when replacing an existing install. " + - "`dotnet new install --force` uninstalls before downloading, so pairing it with a spec " + - "that may not resolve destroys a working template install (exit 103)."); + // Property form of the row above: across every combination, ForcedReplace + // must imply targetExists. This is the invariant that keeps a bad pin from + // uninstalling a working pack. + foreach (var installed in new[] { null, "0.0.6-alpha" }) + foreach (var target in new[] { null, "0.0.7-alpha" }) + foreach (var exists in new[] { true, false }) + foreach (var hasSource in new[] { true, false }) + { + var action = WinAppSdkTemplates.PlanInstall(installed, target, exists, hasSource); + if (action == WinAppSdkTemplates.InstallAction.ForcedReplace) + { + Assert.True(exists, $"PlanInstall forced a replace for an unconfirmed target " + + $"(installed={installed ?? "null"}, target={target ?? "null"}, hasSource={hasSource})."); + Assert.NotNull(installed); + } + } + } + + // ── Credential redaction in the echoed command line ──────────────────── + + [Theory] + [InlineData("https://user:pat@pkgs.example.com/v3/index.json", "pat")] + [InlineData("https://pkgs.example.com/v3/index.json?api-key=SECRET", "SECRET")] + public void RedactSource_strips_credentials_from_feed_urls(string url, string secret) + { + // The install command line is echoed to the console and into CI logs. + var redacted = WinAppSdkTemplates.RedactSource(url); + Assert.DoesNotContain(secret, redacted, StringComparison.Ordinal); + Assert.Contains("pkgs.example.com", redacted, StringComparison.Ordinal); + } + + [Fact] + public void RedactSource_leaves_local_folder_paths_alone() + { + // A folder path carries nothing secret and must stay readable in the echo. + const string folder = @"C:\src\WindowsAppSDK\localpackages"; + Assert.Equal(folder, WinAppSdkTemplates.RedactSource(folder)); + } + + // ── Installed-version parsing ────────────────────────────────────────── + + [Fact] + public void InterpretInstalledVersionOutput_reads_the_version_for_this_pack() + { + // Verbatim shape of `dotnet new uninstall` with two packs installed — the + // other pack's id is a PREFIX-adjacent name, which a substring match would + // confuse with ours. + const string listing = """ + Currently installed items: + Microsoft.WindowsAppSDK.Templates + Version: 0.1.11-prerelease.100 + Details: + Author: Microsoft + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + Version: 0.0.7-alpha + Details: + Author: Microsoft + """; + + Assert.Equal("0.0.7-alpha", WinAppSdkTemplates.InterpretInstalledVersionOutput(listing)); + } + + [Fact] + public void InterpretInstalledVersionOutput_returns_null_when_this_pack_is_absent() + { + const string listing = """ + Currently installed items: + Microsoft.WindowsAppSDK.Templates + Version: 0.1.11-prerelease.100 + """; + + Assert.Null(WinAppSdkTemplates.InterpretInstalledVersionOutput(listing)); + } + + [Fact] + public void InterpretInstalledVersionOutput_returns_null_when_no_version_line_follows() + { + // Malformed / truncated listing must not return a neighbouring package's version. + const string listing = """ + Currently installed items: + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + Details: + Author: Microsoft + """; + + Assert.Null(WinAppSdkTemplates.InterpretInstalledVersionOutput(listing)); } // ── False-PASS guard: "pack installed" != "templates usable" ─────────── @@ -258,31 +357,28 @@ public void Doctor_probes_template_availability_not_just_package_presence() // express the difference, so Install returns an outcome instead. [Fact] - public void InstallOutcome_distinguishes_keeping_an_existing_pack_from_installing() + public void DescribeOutcome_never_claims_an_install_that_did_not_happen() { - // These four non-failure outcomes are not interchangeable: only two of - // them mean the machine actually changed. Collapsing them back to a - // bool/int is what produced the wrong "Installed." message. - var values = Enum.GetNames(); - foreach (var expected in new[] { "Installed", "Updated", "AlreadyCurrent", "KeptExisting", "Failed" }) - Assert.Contains(expected, values); - } - - [Fact] - public void TemplatesCommand_does_not_report_Installed_for_every_outcome() - { - // Source-level guard on the call site — the bug was in the reporting, - // not the install logic, so asserting on Install() alone would miss it. - var (path, text) = ReadRepoFile(global::System.IO.Path.Combine( - "src", "Reactor.Cli", "Templates", "TemplatesCommand.cs")); - var normalized = text.Replace("\r\n", "\n"); - - Assert.Contains("InstallOutcome.KeptExisting", normalized, StringComparison.Ordinal); - Assert.Contains("Kept the existing install", normalized, StringComparison.Ordinal); - Assert.False( - normalized.Contains("Console.WriteLine($\"Installed. Scaffold an app with:\")", StringComparison.Ordinal), - $"'{path}' must not unconditionally print \"Installed.\" — `mur templates install` reports success when it " + - "deliberately keeps an existing pack (nothing resolved), and claiming an install happened there is wrong."); + // Behavioural form of the reporting bug: `mur templates install` printed + // "Installed." while deliberately keeping an existing pack (nothing + // resolvable). Only the two outcomes that actually changed the machine may + // be described as an install/update. + Assert.Equal("Installed.", TemplatesCommand.DescribeOutcome(WinAppSdkTemplates.InstallOutcome.Installed)); + Assert.Equal("Updated.", TemplatesCommand.DescribeOutcome(WinAppSdkTemplates.InstallOutcome.Updated)); + + foreach (var unchanged in new[] + { + WinAppSdkTemplates.InstallOutcome.KeptExisting, + WinAppSdkTemplates.InstallOutcome.AlreadyCurrent, + WinAppSdkTemplates.InstallOutcome.Failed, + }) + { + var message = TemplatesCommand.DescribeOutcome(unchanged); + Assert.False( + message.Contains("Installed.", StringComparison.Ordinal) || + message.Contains("Updated.", StringComparison.Ordinal), + $"{unchanged} did not change the machine but is reported as \"{message}\"."); + } } // ── Bootstrap wiring guards ──────────────────────────────────────────── @@ -372,17 +468,4 @@ static string FindRoot() Assert.NotNull(dir); return dir!; } - - static (string path, string text) ReadCliSource() - { - var dir = AppContext.BaseDirectory; - while (dir != null && !global::System.IO.File.Exists(global::System.IO.Path.Combine(dir, "Reactor.slnx"))) - dir = global::System.IO.Path.GetDirectoryName(dir); - Assert.NotNull(dir); - - var path = global::System.IO.Path.Combine( - dir!, "src", "Reactor.Cli", "Templates", "WinAppSdkTemplates.cs"); - Assert.True(global::System.IO.File.Exists(path), $"Expected '{path}' to exist; file moved or renamed?"); - return (path, global::System.IO.File.ReadAllText(path)); - } } From 2564f6a576c5a362fd238bfdf67ede18b76161bb Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:03:22 -0700 Subject: [PATCH 05/37] Address Copilot review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes all 11 findings. Two were real defects I had missed. CI failure I would have shipped (high): Deleting tools/Templates/ broke Reactor.DocPipeline.Tests/MinimalCsprojDocTests.Documented_package_id_matches_the_scaffolded_template, which loads the deleted Company.ReactorApp1.csproj and asserts it exists. My earlier "suite passes" only covered tests/Reactor.Tests, so this never ran. Confirmed failing, then retargeted: the page's fast path is now a pack outside this repo, so there is no in-repo csproj to cross-check; the test now pins that the documented block names the real framework package id, keeping its positive control. Also emptied the now-stale WinAppSDKReferenceGuardTests allowlist, which existed solely for that template. Credential exposure beyond the echo (high): Redacting the printed command line does not stop another process from reading this process's command line on Windows, where the feed URL is an argument. A --source URL carrying credentials in user-info or the query string is now refused outright, pointing at NuGet.config + a local folder instead. The Process.Start failure diagnostic also formatted the raw argument list; it now redacts like the normal echo. Destructive --force via a pinned URL feed (medium): ResolveAvailableVersions does not query a URL source — it falls through to the public index. A version that exists publicly but is absent from the user's feed therefore set targetExists=true and authorized the --force path, uninstalling a working pack before failing to download. URL sources are now always treated as unverifiable, so a pin against one can never force. Bootstrap reported success it had not verified (medium): `mur templates install` returns success for KeptExisting, which includes keeping a pack too old to contain any Reactor template (0.0.6-alpha shipped without them). Bootstrap printed "templates registered" regardless. It now confirms `dotnet new list reactor` actually resolves, and warns with a remediation otherwise. Documented install commands could not work (low x5): README, the getting-started template, the release runbook and the agent fallback all told users to run a bare `dotnet new install `. By this PR's own reasoning that cannot resolve a prerelease-only pack, and the pack is prerelease-only today. All pinned to ::0.0.7-alpha with the reason stated; CHANGELOG now points at `mur templates install` or the pinned spec. Verified: tests/Reactor.Tests 14,163 passed; tests/Reactor.DocPipeline.Tests 462 passed (was 1 failed); Release build 0 errors; getting-started recompiled. Reactor.IntegrationTests fails locally only on NU1301/TLS reaching nuget.org from temp consumer projects — environmental, unrelated to the extracted fixture, which constructs successfully. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- CHANGELOG.md | 6 +- README.md | 6 +- bootstrap.ps1 | 19 +++++- .../_pipeline/templates/getting-started.md.dt | 6 +- docs/contributing/release-runbook.md | 2 +- docs/guide/getting-started.md | 6 +- plugins/reactor/agents/reactor-dev.agent.md | 2 +- .../Templates/WinAppSdkTemplates.cs | 59 +++++++++++++++---- .../MinimalCsprojDocTests.cs | 29 ++++----- .../WinAppSDKReferenceGuardTests.cs | 10 ++-- 10 files changed, 101 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc877933..fea4dc2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,10 @@ Conventions for contributors: ### Deprecated - **`Microsoft.UI.Reactor.ProjectTemplates` is deprecated on NuGet.org.** Published versions - remain restorable but are marked deprecated with a pointer to `dotnet new reactor`. Use - `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` instead. + remain restorable but are marked deprecated with a pointer to `dotnet new reactor`. Install the + replacement with `mur templates install`, or pin the pack explicitly — + `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha` — since it is + prerelease-only and a bare `dotnet new install` resolves stable versions. ### Removed diff --git a/README.md b/README.md index cb4a588c6..43ced4b8c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ Many of the experiments in this repo — the charting stack, accessibility valid Reactor ships the public preview package [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) on NuGet.org; see the [NuGet page](https://www.nuget.org/packages/Microsoft.UI.Reactor) or [GitHub Releases](https://github.com/microsoft/microsoft-ui-reactor/releases) for the current version. The project templates ship in the official Windows App SDK `dotnet new` pack, so building an app needs no source checkout: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +# The template pack is prerelease-only today, and `dotnet new install` resolves +# stable versions unless you pin one explicitly. +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha dotnet new reactor -n MyApp cd MyApp dotnet run @@ -110,7 +112,7 @@ dotnet run -p:Platform=x64 `bootstrap.ps1` packs `mur` as a `dotnet tool` global install (cross-shell PATH, no per-arch `$env:Path` edits), packs local framework snapshots into `local-nupkgs/`, installs the Windows App SDK `dotnet new` template pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, which provides `dotnet new reactor`), and installs the Reactor agent plugin under `~/.claude/plugins/reactor`. Apps created by the template reference the public `Microsoft.UI.Reactor` package from NuGet.org by default; pass `--reactor-version 0.0.0-local` when you intentionally want a scaffolded app to consume the local source-built package instead. To test an unpublished build of the template pack, run `./bootstrap.ps1 -WinAppSdkTemplatesSource `. The optional `Microsoft.UI.Reactor.Advanced` and `Microsoft.UI.Reactor.Devtools` sibling packages are version-matched to the framework package when published. Re-run `bootstrap.ps1` (or `mur upgrade` for a lighter refresh) after `git pull` when you want updated CLI/plugin bits. Verify a working developer install with `mur doctor`. -> **Scaffolding.** Reactor's `dotnet new` templates ship in the official Windows App SDK template pack — `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, then `dotnet new reactor`. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` package that used to provide `dotnet new reactorapp` has been removed; its published versions are deprecated on NuGet.org. +> **Scaffolding.** Reactor's `dotnet new` templates ship in the official Windows App SDK template pack — `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha`, then `dotnet new reactor`. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` package that used to provide `dotnet new reactorapp` has been removed; its published versions are deprecated on NuGet.org. On networks where the public npm or NuGet registries are unreachable, bootstrap detects a recognised package mirror already configured in the user's `~/.npmrc` and NuGet.Config, verifies unauthenticated package access, and uses it only for the bootstrap process — including the optional Visual Studio extension build. Contributors on an unrestricted network keep the public defaults, unchanged. Any mirror can be selected explicitly with `-NpmRegistry ` and `-NuGetConfig `; credentials remain in user configuration and are never written to the repository. Package feed URLs must use HTTPS (except loopback development feeds) and cannot embed credentials, query strings, or fragments. The npm mirror must permit direct package downloads because the Copilot SDK's MSBuild download task cannot forward npm credentials. diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 89618d73e..e88aff988 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -740,7 +740,24 @@ if ($SkipTemplates) { " - To skip this step entirely: ./bootstrap.ps1 -SkipTemplates" ) -join [Environment]::NewLine) } - Write-Ok '`dotnet new reactor` templates registered' + # `mur templates install` reports success for KeptExisting too — i.e. it kept + # an already-installed pack because nothing newer could be resolved. That pack + # can predate the Reactor templates (0.0.6-alpha shipped without them), so + # confirm the short name actually resolves before claiming success. + $templatesVerified = $false + $listing = & dotnet new list reactor 2>&1 | Out-String + if ($listing -notmatch 'No templates found' -and $listing -match '\breactor\b') { + $templatesVerified = $true + } + + if ($templatesVerified) { + Write-Ok '`dotnet new reactor` templates registered' + } else { + Write-Host '' + Write-Host " [warn] $wasdkTemplatePackageId is installed but does not provide ``dotnet new reactor``." -ForegroundColor Yellow + Write-Host " That version predates the Reactor templates. Re-run with network access, or pin a newer one:" + Write-Host " mur templates install --version " + } } # --------------------------------------------------------------------------- diff --git a/docs/_pipeline/templates/getting-started.md.dt b/docs/_pipeline/templates/getting-started.md.dt index ac51d66c1..2b772617c 100644 --- a/docs/_pipeline/templates/getting-started.md.dt +++ b/docs/_pipeline/templates/getting-started.md.dt @@ -50,7 +50,7 @@ If you just want to build an app, install the template pack and go — you do no need to clone this repo: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha dotnet new reactor -n MyApp cd MyApp dotnet run @@ -220,7 +220,7 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha ``` `mur templates install` does the same thing but resolves the newest published @@ -276,7 +276,7 @@ the install must happen from a shell that isn't already running `mur`). The Reactor templates ship in the Windows App SDK `dotnet new` pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` needs no source checkout. If it's missing, run -`dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — or +`dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha` — or `mur templates install`, which additionally resolves prerelease versions that a bare `dotnet new install` cannot reach. diff --git a/docs/contributing/release-runbook.md b/docs/contributing/release-runbook.md index 332d428ca..8f8c9eafc 100644 --- a/docs/contributing/release-runbook.md +++ b/docs/contributing/release-runbook.md @@ -133,7 +133,7 @@ locally packed one. After the tag is published and the framework package is live confirm a scaffold picks it up: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha $scratch = Join-Path $env:TEMP "reactor-template-smoke" Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 3621793a8..c09b19f59 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -38,7 +38,7 @@ If you just want to build an app, install the template pack and go — you do no need to clone this repo: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha dotnet new reactor -n MyApp cd MyApp dotnet run @@ -208,7 +208,7 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha ``` `mur templates install` does the same thing but resolves the newest published @@ -263,7 +263,7 @@ the install must happen from a shell that isn't already running `mur`). > **Caveat:** The Reactor templates ship in the Windows App SDK `dotnet new` pack > (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` > needs no source checkout. If it's missing, run -> `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — or +> `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha` — or > `mur templates install`, which additionally resolves prerelease versions that a > bare `dotnet new install` cannot reach. diff --git a/plugins/reactor/agents/reactor-dev.agent.md b/plugins/reactor/agents/reactor-dev.agent.md index 15a661e22..ceb124213 100644 --- a/plugins/reactor/agents/reactor-dev.agent.md +++ b/plugins/reactor/agents/reactor-dev.agent.md @@ -21,7 +21,7 @@ user-invocable: true > If the templates aren't installed yet, install the pack before scaffolding: > > ``` -> dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +> dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha > ``` You build Reactor apps in this rhythm: scaffold → understand requirements → draft component tree → write files in a batch → `mur check`. diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index b24d2396d..182bf070d 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -264,18 +264,41 @@ public static InstallOutcome Install(string workingDirectory, string? source = n { var hasSource = !string.IsNullOrWhiteSpace(source); var pinned = !string.IsNullOrWhiteSpace(version); + var urlSourceUnverifiable = false; // A template pack generates code, so refuse to fetch one over plaintext // http:// — a MITM could swap the scaffold. Local folders and https are fine. if (hasSource && Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) && - !sourceUri.IsFile && - !string.Equals(sourceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + !sourceUri.IsFile) { - Console.Error.WriteLine( - $" error: refusing to install a template package from an insecure source " + - $"('{sourceUri.Scheme}'). Use https:// or a local folder."); - return InstallOutcome.Failed; + if (!string.Equals(sourceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine( + $" error: refusing to install a template package from an insecure source " + + $"('{sourceUri.Scheme}'). Use https:// or a local folder."); + return InstallOutcome.Failed; + } + + // Redacting the echo is not enough: the source becomes a child-process + // argument, and on Windows any process can read another's command line. + // A PAT in user-info or the query string would be readable there, so + // refuse it outright and point at the supported ways to authenticate. + if (!string.IsNullOrEmpty(sourceUri.UserInfo) || !string.IsNullOrEmpty(sourceUri.Query)) + { + Console.Error.WriteLine( + " error: refusing a --source URL that carries credentials in its user-info or query " + + "string — it would be visible in this process's command line to any other process. " + + "Configure the feed in NuGet.config (credential provider) and pass a local folder of " + + "nupkgs instead."); + return InstallOutcome.Failed; + } + + // The version listing below only reads local folders and the public + // index, so a pin cannot be confirmed against this feed. Treat it as + // unverified rather than letting a publicly-existing version authorize + // the destructive --force path against a feed that may not have it. + urlSourceUnverifiable = true; } // A URL source cannot be enumerated here (only folders and the public index @@ -297,10 +320,21 @@ public static InstallOutcome Install(string workingDirectory, string? source = n { target = version!.Trim(); // Confirm the pin before considering --force. Null means "couldn't tell", - // which is treated as unverified — never destructive on a maybe. - var available = ResolveAvailableVersions(source); - targetExists = available is not null && - available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); + // which is treated as unverified — never destructive on a maybe. A URL + // source is never confirmable here: the listing below reads only local + // folders and the public index, so a version that exists publicly but is + // absent from the user's feed would otherwise authorize a --force that + // uninstalls first and then fails to download. + if (urlSourceUnverifiable) + { + targetExists = false; + } + else + { + var available = ResolveAvailableVersions(source); + targetExists = available is not null && + available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); + } } else { @@ -468,7 +502,10 @@ static int Run(string workingDirectory, params string[] arguments) } catch (Exception ex) { - Console.Error.WriteLine($" failed to run `dotnet {string.Join(' ', arguments)}`: {ex.Message}"); + // Redact here too — this path formats the same argument list that the + // normal echo redacts, and `--add-source` may carry a feed URL. + Console.Error.WriteLine( + $" failed to run `dotnet {string.Join(' ', arguments.Select(RedactSource))}`: {ex.Message}"); return 1; } } diff --git a/tests/Reactor.DocPipeline.Tests/MinimalCsprojDocTests.cs b/tests/Reactor.DocPipeline.Tests/MinimalCsprojDocTests.cs index 103e97780..47afa384c 100644 --- a/tests/Reactor.DocPipeline.Tests/MinimalCsprojDocTests.cs +++ b/tests/Reactor.DocPipeline.Tests/MinimalCsprojDocTests.cs @@ -105,27 +105,24 @@ public void Documented_csproj_declares_required_winui_properties(string element, } [Fact] - public void Documented_package_id_matches_the_scaffolded_template() + public void Documented_package_id_is_the_framework_package() { - // Cross-check against the real `dotnet new reactorapp` template, which - // the page names as the fast path. If the two ever disagree on which - // package to reference, one of them is lying to the reader. - var repoRoot = FindRepoRoot(); - var templateCsproj = global::System.IO.Path.Join( - repoRoot, "tools", "Templates", "templates", "WinUIApp-CSharp", "Company.ReactorApp1.csproj"); - - Assert.True(global::System.IO.File.Exists(templateCsproj), - $"Expected the scaffolded template at {templateCsproj}; if it moved, update this test."); + // The page names `dotnet new reactor` as the fast path, but those + // templates now live in the Windows App SDK pack and are not present in + // this repo, so there is no in-repo csproj to cross-check against. Pin + // the next best invariant: the hand-authored block documents the real + // framework package id, which is what a reader copies. + var doc = global::System.Xml.Linq.XDocument.Parse(ExtractMinimalSetupCsproj()); - var scaffoldIds = global::System.Xml.Linq.XDocument.Load(templateCsproj) - .Descendants("PackageReference") + var documentedIds = doc.Descendants("PackageReference") .Select(p => p.Attribute("Include")?.Value) + .Where(id => id is not null) .ToList(); - // Positive control: the scaffold really does carry package references, - // so a miss below is a disagreement and not an empty parse. - Assert.NotEmpty(scaffoldIds); - Assert.Contains(PackageId, scaffoldIds); + // Positive control: the block really does carry package references, so a + // miss below is a disagreement and not an empty parse. + Assert.NotEmpty(documentedIds); + Assert.Contains(PackageId, documentedIds); } [Fact] diff --git a/tests/Reactor.Tests/WinAppSDKReferenceGuardTests.cs b/tests/Reactor.Tests/WinAppSDKReferenceGuardTests.cs index f9d05678f..2dddf07df 100644 --- a/tests/Reactor.Tests/WinAppSDKReferenceGuardTests.cs +++ b/tests/Reactor.Tests/WinAppSDKReferenceGuardTests.cs @@ -29,10 +29,12 @@ public class WinAppSDKReferenceGuardTests // Repo-root-relative, '/'-separated csproj paths allowed to reference the // Microsoft.WindowsAppSDK metapackage directly. - private static readonly HashSet Allowlist = new(StringComparer.OrdinalIgnoreCase) - { - "tools/Templates/templates/WinUIApp-CSharp/Company.ReactorApp1.csproj", - }; + // + // Empty since the in-repo `dotnet new reactorapp` template was removed — it + // was the only project that pinned the metapackage itself, because a + // scaffolded consumer does not inherit this repo's central injection. The + // replacement templates live in the Windows App SDK pack, outside this repo. + private static readonly HashSet Allowlist = new(StringComparer.OrdinalIgnoreCase); /// /// Source-level guard: no repo project may declare a direct From 6f0e971707d1aa01b0d1fb33840b9b02e725648f Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:24:49 -0700 Subject: [PATCH 06/37] Harden template detection and credential redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2. Short-name detection matched with Contains("reactor"), which also matches `reactor-mvu` and `winui-reactor` — a pack that shipped the richer shells but not the blank template would read as available, and `mur doctor` would report a false PASS. A `\breactor\b` regex has the same hole, because '-' is a word boundary. Match the short name as a whole token instead, delimited by whitespace, a comma, or the edge of the text (short names appear comma-separated within a whitespace-delimited column). The match is deliberately case-sensitive: the Template Name column carries the capitalised prose word ("Reactor MVU App"), which *is* a standalone token, so a case-insensitive token match still passes when the blank template is absent. The new test caught exactly that. bootstrap.ps1 hand-rolled the same check as a `\breactor\b` regex over `dotnet new list` output, so it carried both bugs. It now calls `mur templates status`, which is the tested implementation, and keys off its exit code. Credential rejection and redaction covered user-info and the query string but not the fragment, which UriBuilder preserves verbatim. A `--source` URL whose fragment carries a PAT would have been echoed to the console and passed on the child process command line, readable by any other process on Windows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 23 ++++++++-- .../Templates/WinAppSdkTemplates.cs | 40 +++++++++++++---- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 45 +++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index e88aff988..4ba8cda5c 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -744,11 +744,28 @@ if ($SkipTemplates) { # an already-installed pack because nothing newer could be resolved. That pack # can predate the Reactor templates (0.0.6-alpha shipped without them), so # confirm the short name actually resolves before claiming success. + # + # Reuse the CLI probe rather than grepping the listing here: `mur templates + # status` matches the short name as a whole token, which a naive regex does + # not — `\breactor\b` also matches `reactor-mvu` and `winui-reactor`, so a + # listing without the blank template would read as success. $templatesVerified = $false - $listing = & dotnet new list reactor 2>&1 | Out-String - if ($listing -notmatch 'No templates found' -and $listing -match '\breactor\b') { - $templatesVerified = $true + $statusExit = 0 + Invoke-ReactorWithRestoreEnvironment ` + -NuGetConfig $effectiveNuGetConfig ` + -NuGetSource $effectiveNuGetSource ` + -ExitCode ([ref]$statusExit) ` + -Action { + $murResolved = Get-Command mur -ErrorAction SilentlyContinue + if ($murResolved) { & mur templates status | Out-Null } + else { + & dotnet run ` + --project (Join-Path $repoRoot 'src\Reactor.Cli\Reactor.Cli.csproj') ` + -c $Configuration "-p:Platform=$hostArch" --nologo ` + -- templates status | Out-Null + } } + $templatesVerified = ($statusExit -eq 0) if ($templatesVerified) { Write-Ok '`dotnet new reactor` templates registered' diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 182bf070d..66c5355bb 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -30,6 +30,7 @@ using System.Diagnostics; using System.Net.Http; +using System.Text.RegularExpressions; using Microsoft.UI.Reactor.Cli.Pack; namespace Microsoft.UI.Reactor.Cli.Templates; @@ -114,9 +115,28 @@ internal static bool InterpretTemplateListOutput(string output) // precisely when it is absent. Check the negative marker first. if (output.Contains("No templates found", StringComparison.OrdinalIgnoreCase)) return false; - return output.Contains(BlankShortName, StringComparison.OrdinalIgnoreCase); + + // Match the short name as a whole token. A plain Contains (or a \b regex) + // also matches `reactor-mvu` and `winui-reactor`, because '-' is a word + // boundary — so a listing that has the richer shells but not the blank + // template would be read as success. Short names are comma-separated + // within a whitespace-delimited column, so require one of those delimiters + // on each side. + // + // Deliberately case-sensitive: the Template Name column carries the + // capitalised prose word ("Reactor MVU App"), which is a standalone token + // and would match case-insensitively even when the blank template is + // absent. Short names are lowercase and BlankShortName is a constant. + return Regex.IsMatch(output, BlankShortNameTokenPattern); } + /// + /// as a whole token: preceded and followed by a + /// comma, whitespace, or the edge of the text. + /// + private static readonly string BlankShortNameTokenPattern = + @"(? /// The installed version of the template pack, or null when it isn't /// installed (or the listing couldn't be read). @@ -241,6 +261,7 @@ internal static string RedactSource(string source) UserName = string.IsNullOrEmpty(uri.UserInfo) ? string.Empty : "***", Password = string.Empty, Query = string.IsNullOrEmpty(uri.Query) ? string.Empty : "***", + Fragment = string.IsNullOrEmpty(uri.Fragment) ? string.Empty : "***", }; return builder.Uri.ToString(); } @@ -282,15 +303,18 @@ public static InstallOutcome Install(string workingDirectory, string? source = n // Redacting the echo is not enough: the source becomes a child-process // argument, and on Windows any process can read another's command line. - // A PAT in user-info or the query string would be readable there, so - // refuse it outright and point at the supported ways to authenticate. - if (!string.IsNullOrEmpty(sourceUri.UserInfo) || !string.IsNullOrEmpty(sourceUri.Query)) + // A PAT in user-info, the query string or the fragment would be readable + // there, so refuse it outright and point at the supported ways to + // authenticate. + if (!string.IsNullOrEmpty(sourceUri.UserInfo) || + !string.IsNullOrEmpty(sourceUri.Query) || + !string.IsNullOrEmpty(sourceUri.Fragment)) { Console.Error.WriteLine( - " error: refusing a --source URL that carries credentials in its user-info or query " + - "string — it would be visible in this process's command line to any other process. " + - "Configure the feed in NuGet.config (credential provider) and pass a local folder of " + - "nupkgs instead."); + " error: refusing a --source URL that carries credentials in its user-info, query " + + "string or fragment — it would be visible in this process's command line to any other " + + "process. Configure the feed in NuGet.config (credential provider) and pass a local " + + "folder of nupkgs instead."); return InstallOutcome.Failed; } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 3a89662d8..a701364ba 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -289,6 +289,51 @@ public void InterpretInstalledVersionOutput_returns_null_when_no_version_line_fo Assert.Null(WinAppSdkTemplates.InterpretInstalledVersionOutput(listing)); } + [Fact] + public void InterpretTemplateListOutput_requires_the_exact_short_name() + { + // Two traps in one fixture: + // • '-' is a word boundary, so `\breactor\b` / Contains("reactor") also + // matches `reactor-mvu` and `winui-reactor`; + // • the Template Name column carries the capitalised prose word + // "Reactor" as a standalone token, so a case-insensitive token match + // passes too. + // Neither means the blank `reactor` template is installed. + const string withoutBlank = """ + These templates matched your input: 'reactor' + + Template Name Short Name Language + --------------------------------- ----------------------------- -------- + Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] + """; + + Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput(withoutBlank)); + } + + [Fact] + public void InterpretTemplateListOutput_accepts_the_short_name_in_a_comma_list() + { + // Real listings put the blank template's aliases in one comma-separated + // column, so the token match must survive commas on both sides. + const string withBlank = """ + These templates matched your input: 'reactor' + + Template Name Short Name Language + --------------------------------- ----------------------------- -------- + Reactor Blank App (Experimental) reactor,reactor-blank [C#] + """; + + Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank)); + } + + [Fact] + public void RedactSource_strips_a_credential_bearing_fragment() + { + // UriBuilder preserves the fragment, so it has to be masked explicitly. + var redacted = WinAppSdkTemplates.RedactSource("https://feed.example.com/v3/index.json#PAT"); + Assert.DoesNotContain("PAT", redacted, StringComparison.Ordinal); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had From 696b8b5f2e8730d92e8246486ac599f5b529856c Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:41:17 -0700 Subject: [PATCH 07/37] Require --source to be a local folder, and fix the CI template guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 3. `dotnet new install` has no feed-isolation switch — `--add-source` only *adds* to the configured sources. So `mur templates install --source --version X` could be satisfied from nuget.org instead of the requested feed, installing a different package. The version pin cannot disambiguate them either: an unpublished build and the published pack routinely carry the same version string (a locally packed 0.0.7-alpha vs the published 0.0.7-alpha). A local folder has neither problem — it is enumerable, so a pin is confirmable, and `--add-source ` with an exact version resolves the file that is actually there. `dotnet new install` also ignores the NuGet credential provider, so an authenticated feed URL fails anyway with a misleading "the package does not exist". So require a folder and point at the restore-then-install-from-cache workflow, which is what the help text and bootstrap.ps1 already described. That subsumes the previous URL-specific guards (insecure scheme, credentials in user-info/query/fragment) and makes the property stronger: no URL now reaches the child process command line at all. PlanInstall gains the matching rule — with an explicit source, refuse anything not confirmed to be in it, including the empty-folder case where no version resolves and a bare package id would fall through to the configured feeds. The previous table allowed that whenever nothing was installed. The bootstrap CI guard verified each short name with a plain substring test, so the `reactor` probe was satisfied by `reactor-mvu` — the same prefix-matching hole just fixed in the CLI. It now uses the same delimited token match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 11 +- src/Reactor.Cli/Templates/TemplatesCommand.cs | 7 +- .../Templates/WinAppSdkTemplates.cs | 124 ++++++++---------- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 31 +++++ 4 files changed, 104 insertions(+), 69 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 3a49231a0..a04c9ca4f 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -179,8 +179,17 @@ jobs: # template is present. Join + match to do a whole-output substring # check instead. $joined = $listing -join "`n" + # Match each short name as a whole token. A plain substring check (or a + # \b regex) also matches `reactor-mvu` and `winui-reactor`, because '-' + # is a word boundary — so a pack carrying only the richer shells would + # pass the `reactor` probe. Short names are comma-separated inside a + # whitespace-delimited column, so require one of those on each side. + # Case-sensitive on purpose: the Template Name column carries the + # capitalised prose word ("Reactor MVU App"), which is a standalone + # token and would satisfy a case-insensitive match on its own. foreach ($shortName in @('reactor', 'reactor-mvu', 'reactor-navview', 'reactor-tabview')) { - if ($joined -notmatch [regex]::Escape($shortName)) { + $token = '(? Extra NuGet source. Point at a folder of nupkgs to -// test an unpublished build of the pack. +// --source Folder of .nupkg files, to test an unpublished build of +// the pack. Must be a local folder, not a feed URL: +// `dotnet new install` cannot be restricted to one feed +// (--add-source only adds one), so a URL source can be +// silently satisfied from nuget.org instead. // --version Pin an explicit version instead of resolving. namespace Microsoft.UI.Reactor.Cli.Templates; diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 66c5355bb..f602af26f 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -227,6 +227,19 @@ internal enum InstallAction /// True when an extra NuGet source was supplied. internal static InstallAction PlanInstall(string? installed, string? target, bool targetExists, bool hasSource) { + // An explicit source says "install the build that is *here*". But + // `dotnet new install` has no feed-isolation switch — `--add-source` only + // *adds* to the configured feeds — so anything we cannot confirm in that + // source gets resolved from nuget.org instead. That is a different package, + // frequently under the very same version string (a locally packed + // 0.0.7-alpha vs the published 0.0.7-alpha), so neither the id nor the pin + // would reveal the substitution. Sources are local folders by the time we + // get here (URL sources are rejected up front), so the listing is + // authoritative and absence is definitive: refuse rather than install + // something the caller did not point at. + if (hasSource && (target is null || !targetExists)) + return InstallAction.RefuseUnverifiedPin; + if (target is null) return installed is not null ? InstallAction.KeepExisting : InstallAction.PlainInstall; @@ -285,54 +298,33 @@ public static InstallOutcome Install(string workingDirectory, string? source = n { var hasSource = !string.IsNullOrWhiteSpace(source); var pinned = !string.IsNullOrWhiteSpace(version); - var urlSourceUnverifiable = false; - - // A template pack generates code, so refuse to fetch one over plaintext - // http:// — a MITM could swap the scaffold. Local folders and https are fine. - if (hasSource && - Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) && - !sourceUri.IsFile) - { - if (!string.Equals(sourceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - Console.Error.WriteLine( - $" error: refusing to install a template package from an insecure source " + - $"('{sourceUri.Scheme}'). Use https:// or a local folder."); - return InstallOutcome.Failed; - } - - // Redacting the echo is not enough: the source becomes a child-process - // argument, and on Windows any process can read another's command line. - // A PAT in user-info, the query string or the fragment would be readable - // there, so refuse it outright and point at the supported ways to - // authenticate. - if (!string.IsNullOrEmpty(sourceUri.UserInfo) || - !string.IsNullOrEmpty(sourceUri.Query) || - !string.IsNullOrEmpty(sourceUri.Fragment)) - { - Console.Error.WriteLine( - " error: refusing a --source URL that carries credentials in its user-info, query " + - "string or fragment — it would be visible in this process's command line to any other " + - "process. Configure the feed in NuGet.config (credential provider) and pass a local " + - "folder of nupkgs instead."); - return InstallOutcome.Failed; - } - - // The version listing below only reads local folders and the public - // index, so a pin cannot be confirmed against this feed. Treat it as - // unverified rather than letting a publicly-existing version authorize - // the destructive --force path against a feed that may not have it. - urlSourceUnverifiable = true; - } - - // A URL source cannot be enumerated here (only folders and the public index - // are), so without a pin we would silently resolve a version from nuget.org - // and then install it from the user's feed — a different package than asked for. - if (hasSource && !pinned && !Directory.Exists(source)) + // `--source` must be a local folder of nupkgs. + // + // `dotnet new install` has no feed-isolation switch: `--add-source` only + // *augments* the configured sources. So `::` passed alongside + // a feed URL can be satisfied from nuget.org instead, silently installing a + // different package than the one asked for. That is not theoretical — an + // unpublished build and the published pack routinely carry the *same* + // version string (a local `0.0.7-alpha` vs the published `0.0.7-alpha`), so + // the version pin cannot disambiguate them either. + // + // A folder has neither problem: it is enumerable, so a pin is confirmable, + // and `--add-source ` plus an exact version resolves to the file + // that is actually there. `dotnet new install` also ignores the NuGet + // credential provider, so an authenticated feed URL fails anyway with a + // misleading "the package does not exist". Restore first, then point at the + // cache folder. + if (hasSource && !Directory.Exists(source)) { + // Redact before echoing: a feed URL can carry a PAT, and this line lands + // in console output and CI logs. + Console.Error.WriteLine( + $" error: --source '{RedactSource(source!)}' is not a local folder. `dotnet new install` " + + "cannot be restricted to a single feed (--add-source only adds one), so a feed URL can " + + "silently resolve the package from somewhere else. Restore the package first, then pass " + + "the folder holding the .nupkg:"); Console.Error.WriteLine( - $" error: --source '{RedactSource(source!)}' is not a local folder, and versions cannot be " + - $"enumerated from a feed URL here. Pass an explicit --version to install from it."); + $" mur templates install --source %USERPROFILE%\\.nuget\\packages\\{PackageId.ToLowerInvariant()}\\"); return InstallOutcome.Failed; } @@ -344,21 +336,12 @@ public static InstallOutcome Install(string workingDirectory, string? source = n { target = version!.Trim(); // Confirm the pin before considering --force. Null means "couldn't tell", - // which is treated as unverified — never destructive on a maybe. A URL - // source is never confirmable here: the listing below reads only local - // folders and the public index, so a version that exists publicly but is - // absent from the user's feed would otherwise authorize a --force that - // uninstalls first and then fails to download. - if (urlSourceUnverifiable) - { - targetExists = false; - } - else - { - var available = ResolveAvailableVersions(source); - targetExists = available is not null && - available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); - } + // which is treated as unverified — never destructive on a maybe. With a + // source, the source is a local folder (URL sources are rejected above), + // so its listing is authoritative: absent really means absent. + var available = ResolveAvailableVersions(source); + targetExists = available is not null && + available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); } else { @@ -380,11 +363,20 @@ public static InstallOutcome Install(string workingDirectory, string? source = n return InstallOutcome.AlreadyCurrent; case InstallAction.RefuseUnverifiedPin: - Console.Error.WriteLine( - $" error: {PackageId} {target} could not be found in the configured sources, and " + - $"replacing an install requires `--force`, which uninstalls the current {installed} " + - $"before downloading. Refusing, so your working install survives. " + - $"Check the version, or pass --source with the folder that has it."); + Console.Error.WriteLine(hasSource + ? (target is null + ? $" error: no {PackageId} .nupkg found in --source '{RedactSource(source!)}'. " + + $"Installing anyway would resolve the package from another configured feed, " + + $"because `--add-source` only adds to the configured sources. Point --source at " + + $"a folder that has it." + : $" error: {PackageId} {target} is not in --source '{RedactSource(source!)}'. " + + $"Installing anyway would let `--add-source` resolve that version from another " + + $"configured feed — a different package under the same version string. Check the " + + $"version, or point --source at the folder that has it.") + : $" error: {PackageId} {target} could not be found in the configured sources, and " + + $"replacing an install requires `--force`, which uninstalls the current {installed} " + + $"before downloading. Refusing, so your working install survives. " + + $"Check the version, or pass --source with the folder that has it."); return InstallOutcome.Failed; case InstallAction.PlainInstall: diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index a701364ba..2248fe8b5 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -191,6 +191,15 @@ public void ResolveLatestVersion_returns_null_for_an_empty_local_source() [InlineData("0.0.6-alpha", "0.0.9-nope", false, false, "RefuseUnverifiedPin")] // An explicit source means "install from here", so an equal version still installs. [InlineData("0.0.7-alpha", "0.0.7-alpha", true, true, "ForcedReplace")] + // With a source, a version that isn't in it must be refused even with nothing + // installed: --add-source only ADDS a feed, so `::` would be + // satisfied from nuget.org instead — a different package, same version string. + [InlineData(null, "0.0.7-alpha", false, true, "RefuseUnverifiedPin")] + [InlineData("0.0.6-alpha", "0.0.7-alpha", false, true, "RefuseUnverifiedPin")] + // Same hazard with no version resolvable at all (an empty --source folder): + // a bare package id resolves from the configured feeds, not from the folder. + [InlineData(null, null, false, true, "RefuseUnverifiedPin")] + [InlineData("0.0.6-alpha", null, false, true, "RefuseUnverifiedPin")] public void PlanInstall_only_forces_for_a_confirmed_target( string? installed, string? target, bool targetExists, bool hasSource, string expected) { @@ -198,6 +207,28 @@ public void PlanInstall_only_forces_for_a_confirmed_target( Assert.Equal(expected, actual.ToString()); } + [Fact] + public void PlanInstall_never_installs_from_an_unconfirmed_source() + { + // Property form: whenever an explicit --source was given, no action that + // shells out to `dotnet new install` may be chosen unless the target was + // confirmed to exist in that source. Otherwise `--add-source` silently + // resolves the package from a different feed. + foreach (var installed in new[] { null, "0.0.6-alpha" }) + foreach (var target in new[] { null, "0.0.7-alpha" }) + foreach (var exists in new[] { true, false }) + { + var action = WinAppSdkTemplates.PlanInstall(installed, target, exists, hasSource: true); + if (action is WinAppSdkTemplates.InstallAction.PlainInstall + or WinAppSdkTemplates.InstallAction.ForcedReplace) + { + Assert.True(exists && target is not null, + $"PlanInstall chose {action} against an unconfirmed --source " + + $"(installed={installed ?? "null"}, target={target ?? "null"}, targetExists={exists})."); + } + } + } + [Fact] public void PlanInstall_never_forces_an_unconfirmed_target() { From 1fef63a059bdd674cb776d535025658db45728dd Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:47:07 -0700 Subject: [PATCH 08/37] Address code-quality findings on the template migration GitHub code-quality review, 10 findings. Generic `catch` clauses narrowed to the exceptions the guarded operation can actually raise, so a bug inside the try block surfaces instead of being swallowed as "offline" or "best-effort cleanup": - the NuGet flat-container query to HttpRequestException / TaskCanceledException / JsonException, - local nupkg enumeration to IOException / UnauthorizedAccessException / ArgumentException, - both `dotnet` process launches to Win32Exception / InvalidOperationException / IOException, - the two temp-directory cleanups in the tests and the integration-test fixture to IOException / UnauthorizedAccessException. EnumerateLocalVersions now maps with Select instead of an accumulate-only foreach. `Path.Combine(repoRoot, "local-nupkgs")` became `Path.Join`: Combine discards everything before a rooted later argument, which is not what this call wants. Dropped a null-coalesce on a value the compiler already knows is non-null on that branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 22 ++++++++++--------- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 2 +- .../Packaging/LocalPackageFeedFixture.cs | 2 +- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 8 ++++--- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index f602af26f..15054e54b 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -28,8 +28,10 @@ // then failed with "the package does not exist".) We therefore only pass // `--force` when replacing an install with a version we already know exists. +using System.ComponentModel; using System.Diagnostics; using System.Net.Http; +using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.UI.Reactor.Cli.Pack; @@ -446,7 +448,7 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string var json = http.GetStringAsync(FlatContainerIndexUrl).GetAwaiter().GetResult(); return PackLocalCommand.ParseFlatContainerVersions(json); } - catch (Exception ex) + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) { Console.Error.WriteLine( $" warning: could not query NuGet for {PackageId} versions " + @@ -486,14 +488,14 @@ internal static IReadOnlyList EnumerateLocalVersions(string folder) var versions = new List(); try { - foreach (var file in Directory.EnumerateFiles(folder, $"{PackageId}.*.nupkg")) - { - var name = Path.GetFileNameWithoutExtension(file); - if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - versions.Add(name[prefix.Length..]); - } + versions.AddRange(Directory + .EnumerateFiles(folder, $"{PackageId}.*.nupkg") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => name is not null && + name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(name => name![prefix.Length..])); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { Console.Error.WriteLine($" warning: could not enumerate '{folder}' ({ex.GetType().Name}: {ex.Message})."); } @@ -516,7 +518,7 @@ static int Run(string workingDirectory, params string[] arguments) proc.WaitForExit(); return proc.ExitCode; } - catch (Exception ex) + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) { // Redact here too — this path formats the same argument list that the // normal echo redacts, and `--add-source` may carry a feed URL. @@ -547,7 +549,7 @@ static int Run(string workingDirectory, params string[] arguments) // while still printing a usable listing, so don't gate on ExitCode. return stdout + stderr; } - catch + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) { return null; } diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index b1ef8f416..f89cd3fa5 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -136,7 +136,7 @@ public static int Run(string[] args) Console.WriteLine(); Console.WriteLine("Upgrade complete."); Console.WriteLine(); - var feed = Path.Combine(repoRoot, "local-nupkgs"); + var feed = Path.Join(repoRoot, "local-nupkgs"); Console.WriteLine(" To bump `mur` itself (which can't update its own running process), run:"); Console.WriteLine($" dotnet tool update -g --add-source \"{feed}\" Microsoft.UI.Reactor.Cli"); Console.WriteLine(" Or just re-run ./bootstrap.ps1 from the repo root."); diff --git a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs index 8dcce698c..d45db7645 100644 --- a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs +++ b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs @@ -96,7 +96,7 @@ public void Dispose() Directory.Delete(_tempRoot, recursive: true); } } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Best-effort cleanup for shared package-setup artifacts. } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 2248fe8b5..1abe2683c 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -121,7 +121,8 @@ public void EnumerateLocalVersions_reads_versions_off_nupkg_filenames() } finally { - try { global::System.IO.Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + try { global::System.IO.Directory.Delete(dir, recursive: true); } + catch (Exception ex) when (ex is global::System.IO.IOException or UnauthorizedAccessException) { /* best-effort */ } } } @@ -162,7 +163,8 @@ public void ResolveLatestVersion_returns_null_for_an_empty_local_source() } finally { - try { global::System.IO.Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + try { global::System.IO.Directory.Delete(dir, recursive: true); } + catch (Exception ex) when (ex is global::System.IO.IOException or UnauthorizedAccessException) { /* best-effort */ } } } @@ -244,7 +246,7 @@ public void PlanInstall_never_forces_an_unconfirmed_target() if (action == WinAppSdkTemplates.InstallAction.ForcedReplace) { Assert.True(exists, $"PlanInstall forced a replace for an unconfirmed target " + - $"(installed={installed ?? "null"}, target={target ?? "null"}, hasSource={hasSource})."); + $"(installed={installed}, target={target ?? "null"}, hasSource={hasSource})."); Assert.NotNull(installed); } } From 7dd5346246333ab1a366bf7f0888e52bca7eed25 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:05:26 -0700 Subject: [PATCH 09/37] Restore packaging.md to the solid doc tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the in-repo template package removed a `snippet=` reference from packaging.md.dt, dropping it to 2 resolved snippets. The solid tier requires 3, so REACTOR_DOC_TIER_003 failed both the "Docs build" and "Docs tier-drift" CI jobs. Rather than pad the count, add the snippet the MSIX section was missing: the identity block of this repo's own packaged selftest host, which is a real single-project MSIX app. The prose said "Package.appxmanifest declares the package identity" and then showed no manifest at all. Also corrects the publish-shape table, which still labelled the unpackaged row "(template default)". The Windows App SDK `reactor` templates scaffold a packaged app, so MSIX is the default now — the page's own opening paragraph already said so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- docs/_pipeline/templates/packaging.md.dt | 15 ++++++++-- docs/guide/packaging.md | 29 +++++++++++++++++-- .../Package.appxmanifest | 2 ++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/_pipeline/templates/packaging.md.dt b/docs/_pipeline/templates/packaging.md.dt index e76de27bc..df797f61b 100644 --- a/docs/_pipeline/templates/packaging.md.dt +++ b/docs/_pipeline/templates/packaging.md.dt @@ -32,8 +32,8 @@ INPC walker). | Publish shape | Key properties | Runtime identifier | What you get | |---|---|---|---| -| Unpackaged (template default) | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | -| MSIX | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | +| Unpackaged | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | +| MSIX (template default) | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | | Single-file | `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true` | `win-x64` / `win-arm64` (must be set) | One `.exe` that self-extracts the WinUI runtime to `%TEMP%/.net/` on first launch. | | Native AOT | `PublishAot=true`, `InvariantGlobalization=true` (recommended) | `win-x64` / `win-arm64` (required) | A native binary with no JIT, no `Assembly.GetTypes()`, no `Reflection.Emit`. Fastest cold start; trim-only. | @@ -112,7 +112,16 @@ shape adds three properties on top of the unpackaged CSPROJ: ``` `Package.appxmanifest` declares the package identity (Publisher, -PackageFamilyName, capabilities, file-type associations). The +PackageFamilyName, capabilities, file-type associations). Here is the +identity block from this repo's own packaged selftest host, which is a +real single-project MSIX app: + +```xml snippet="source:tests/Reactor.PackagedTests.Host/Package.appxmanifest#msix-identity" +``` + +`Publisher` must match the subject of the signing certificate, and +`Name` plus `Publisher` together derive the PackageFamilyName that +Windows uses to identify the app. The [WinUI 3 packaging docs](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/packaging/) cover the manifest surface in full. The signing certificate is either a Microsoft Store-issued cert (for Store submissions) or a diff --git a/docs/guide/packaging.md b/docs/guide/packaging.md index 339a51195..ac2fe46e6 100644 --- a/docs/guide/packaging.md +++ b/docs/guide/packaging.md @@ -17,8 +17,8 @@ INPC walker). | Publish shape | Key properties | Runtime identifier | What you get | |---|---|---|---| -| Unpackaged (template default) | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | -| MSIX | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | +| Unpackaged | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | +| MSIX (template default) | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | | Single-file | `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true` | `win-x64` / `win-arm64` (must be set) | One `.exe` that self-extracts the WinUI runtime to `%TEMP%/.net/` on first launch. | | Native AOT | `PublishAot=true`, `InvariantGlobalization=true` (recommended) | `win-x64` / `win-arm64` (required) | A native binary with no JIT, no `Assembly.GetTypes()`, no `Reflection.Emit`. Fastest cold start; trim-only. | @@ -106,7 +106,30 @@ shape adds three properties on top of the unpackaged CSPROJ: ``` `Package.appxmanifest` declares the package identity (Publisher, -PackageFamilyName, capabilities, file-type associations). The +PackageFamilyName, capabilities, file-type associations). Here is the +identity block from this repo's own packaged selftest host, which is a +real single-project MSIX app: + +```xml + + + + Reactor Packaged Test Host + Microsoft.UI.Reactor + Images\StoreLogo.png + + + + + +``` + +`Publisher` must match the subject of the signing certificate, and +`Name` plus `Publisher` together derive the PackageFamilyName that +Windows uses to identify the app. The [WinUI 3 packaging docs](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/packaging/) cover the manifest surface in full. The signing certificate is either a Microsoft Store-issued cert (for Store submissions) or a diff --git a/tests/Reactor.PackagedTests.Host/Package.appxmanifest b/tests/Reactor.PackagedTests.Host/Package.appxmanifest index 7987467f0..cdc294a33 100644 --- a/tests/Reactor.PackagedTests.Host/Package.appxmanifest +++ b/tests/Reactor.PackagedTests.Host/Package.appxmanifest @@ -8,6 +8,7 @@ + + From dcf855ad4e8cac09fac580c486efc9df9e693111 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:23:27 -0700 Subject: [PATCH 10/37] Resolve template versions through the configured feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4. The version resolver hard-coded api.nuget.org's flat container. On a machine that reaches its configured mirror but not nuget.org, resolution returned null; with nothing installed the install then fell back to a bare package id, which cannot reach a prerelease-only pack — so bootstrap failed with a usable feed sitting right there. `mur templates install` now takes `--feed ` (also `mur upgrade --templates-feed`), used for version lookup only and never passed to `dotnet new install`. bootstrap.ps1 passes the source its feed resolver already selected. Resolution reads PackageBaseAddress out of the service index rather than guessing a /flatcontainer/ path — service-index hosts generally have no such path and 404 for every package, including ones that exist. Falls back to nuget.org when the feed cannot be read. Verified against the real internal feed: the composed URL returns 122 versions for the newtonsoft.json control and 5 for the template pack. Also in this round: - The bootstrap CI guard token-matched before checking the "No templates found" marker. That output repeats the search term and prints a `dotnet new search reactor` hint, both whole-token matches — so the probe reported success exactly when the template was absent. Confirmed against real not-found text. - `mur upgrade --templates-source` / `--templates-version` were silently ignored as the final argument, running an unpinned upgrade instead of reporting the missing value. ParseFlag now scans the whole argv and reports it. - The file header still described the legacy ProjectTemplates pack as built by `mur pack-local` and published from the release workflow; this PR deleted both. Help text, XML docs and the bootstrap.ps1 parameter docs still offered feed URLs as a `--source`, which is now rejected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 8 ++ bootstrap.ps1 | 21 ++- src/Reactor.Cli/Templates/TemplatesCommand.cs | 22 +++- .../Templates/WinAppSdkTemplates.cs | 123 ++++++++++++++++-- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 36 ++++- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 48 +++++++ 6 files changed, 227 insertions(+), 31 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index a04c9ca4f..80de414bb 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -179,6 +179,14 @@ jobs: # template is present. Join + match to do a whole-output substring # check instead. $joined = $listing -join "`n" + # The "not found" message repeats the search term and prints a + # `dotnet new search reactor` hint, both of which contain `reactor` as + # a whole token — so the token match below reports success precisely + # when the template is absent. Check the negative marker first, exactly + # as InterpretTemplateListOutput does. + if ($joined -match 'No templates found') { + throw "``dotnet new list reactor`` found no templates" + } # Match each short name as a whole token. A plain substring check (or a # \b regex) also matches `reactor-mvu` and `winui-reactor`, because '-' # is a word boundary — so a pack carrying only the richer shells would diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 4ba8cda5c..7e068d53d 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -64,12 +64,18 @@ in effect. .PARAMETER WinAppSdkTemplatesSource - Extra NuGet source (local folder or feed URL) to resolve the Windows App - SDK `dotnet new` template pack from. Use this to test an unpublished build - of `Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — point it at the + Local folder of .nupkg files to install the Windows App SDK `dotnet new` + template pack from. Use this to test an unpublished build of + `Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — point it at the `dotnet pack` output of a WindowsAppSDK checkout. Layered on top of the configured sources via `dotnet new install --add-source`. + Must be a local folder: `dotnet new install` has no feed-isolation switch + (`--add-source` only adds to the configured sources), so a feed URL can be + silently satisfied from somewhere else — often under the very same version + string. To install from an authenticated feed, `dotnet restore` the package + first and pass the cache folder here. + .PARAMETER WinAppSdkTemplatesVersion Pin the Windows App SDK template pack to an explicit version. By default bootstrap resolves the newest published version — the newest stable when one @@ -703,6 +709,15 @@ if ($SkipTemplates) { Write-Dbg "Template version pin: $WinAppSdkTemplatesVersion" $murTemplateArgs += @('--version', $WinAppSdkTemplatesVersion) } + # Resolve the version through the feed this clone is actually configured + # against. Without it the resolver only knows nuget.org, so on a machine that + # reaches the mirror but not nuget.org it resolves nothing and falls back to a + # bare package id — which cannot reach a prerelease-only pack, failing the step + # even though a usable feed was right there. + if ($effectiveNuGetSource) { + Write-Dbg "Template version feed: $effectiveNuGetSource" + $murTemplateArgs += @('--feed', $effectiveNuGetSource) + } $templatesExit = 0 Invoke-ReactorWithRestoreEnvironment ` diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index baaa4a44b..f1da3fb94 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -61,7 +61,7 @@ static int Install(string[] args) // Reject anything we don't understand rather than silently ignoring it — // a typo like `--sorce ./pkgs` would otherwise install from the wrong place. - if (!TryParseInstallArgs(args, out var source, out var version, out var error)) + if (!TryParseInstallArgs(args, out var source, out var version, out var feed, out var error)) { Console.Error.WriteLine($"mur templates install: {error}"); Console.Error.WriteLine(); @@ -76,7 +76,7 @@ static int Install(string[] args) if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) source = Path.GetFullPath(source!); - var outcome = WinAppSdkTemplates.Install(Directory.GetCurrentDirectory(), source, version); + var outcome = WinAppSdkTemplates.Install(Directory.GetCurrentDirectory(), source, version, feed); if (outcome == WinAppSdkTemplates.InstallOutcome.Failed) { Console.Error.WriteLine(); @@ -165,16 +165,21 @@ static void ShowHelp() static void ShowInstallHelp() { - Console.WriteLine("Usage: mur templates install [--source ] [--version ]"); + Console.WriteLine("Usage: mur templates install [--source ] [--version ] [--feed ]"); Console.WriteLine(); Console.WriteLine($"Installs {WinAppSdkTemplates.PackageId}. With no options it resolves the"); Console.WriteLine("newest published version (newest stable, else newest prerelease)."); Console.WriteLine(); Console.WriteLine("Options:"); - Console.WriteLine(" --source Folder of .nupkg files, to install an unpublished build."); - Console.WriteLine(" A feed URL also works but cannot be enumerated, so it"); - Console.WriteLine(" requires --version."); + Console.WriteLine(" --source Folder of .nupkg files, to install an unpublished build."); + Console.WriteLine(" Must be a local folder — feed URLs are rejected, because"); + Console.WriteLine(" `dotnet new install` cannot be restricted to one feed and"); + Console.WriteLine(" would silently accept the package from another. Restore"); + Console.WriteLine(" the package first, then point at the cache folder."); Console.WriteLine(" --version Pin an explicit version instead of resolving."); + Console.WriteLine(" --feed NuGet v3 service index to resolve the version from, for"); + Console.WriteLine(" machines that reach a mirror but not nuget.org. Used only"); + Console.WriteLine(" for version lookup; falls back to nuget.org."); Console.WriteLine(" --help, -h Show this help."); } @@ -183,10 +188,11 @@ static void ShowInstallHelp() /// arguments, and flags with a missing value, so a typo cannot silently change /// what gets installed. /// - static bool TryParseInstallArgs(string[] args, out string? source, out string? version, out string? error) + static bool TryParseInstallArgs(string[] args, out string? source, out string? version, out string? feed, out string? error) { source = null; version = null; + feed = null; error = null; for (var i = 0; i < args.Length; i++) @@ -196,12 +202,14 @@ static bool TryParseInstallArgs(string[] args, out string? source, out string? v { case "--source": case "--version": + case "--feed": if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) { error = $"'{arg}' requires a value."; return false; } if (arg == "--source") source = args[++i]; + else if (arg == "--feed") feed = args[++i]; else version = args[++i]; break; default: diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 15054e54b..1dcbe3165 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -5,8 +5,9 @@ // (`dotnet new reactorapp`, unpackaged). The Windows App SDK template pack now // carries first-class Reactor templates alongside the WinUI 3 XAML ones, so // that's what `bootstrap.ps1` installs and what `mur doctor` looks for. The -// legacy pack is still built by `mur pack-local` and published from the release -// workflow, but nothing installs it automatically any more. +// legacy pack's source has been deleted from this repo and it is no longer +// built or published — the versions already on NuGet.org are all that remain, +// and they are deprecated. // // Two user-visible differences from the legacy `reactorapp` template: // • the short name is `reactor` (plus `reactor-mvu` / `reactor-navview` / @@ -58,6 +59,49 @@ public static class WinAppSdkTemplates const string FlatContainerIndexUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.windowsappsdk.winui.csharp.templates/index.json"; + /// + /// The `PackageBaseAddress/3.0.0` resource (the flat container) advertised by a + /// NuGet v3 service index, or null when the document doesn't declare one. + /// + /// + /// A service index host generally has no `/flatcontainer/` path of its own, so + /// guessing one returns 404 for every package — including ones that certainly + /// exist. The base address has to be read out of the index. + /// + internal static string? ParsePackageBaseAddress(string serviceIndexJson) + { + try + { + using var doc = JsonDocument.Parse(serviceIndexJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object || + !doc.RootElement.TryGetProperty("resources", out var resources) || + resources.ValueKind != JsonValueKind.Array) + return null; + + foreach (var resource in resources.EnumerateArray()) + { + if (resource.ValueKind != JsonValueKind.Object) continue; + if (!resource.TryGetProperty("@type", out var type) || + type.ValueKind != JsonValueKind.String) continue; + // The version suffix has moved across service-index revisions + // (3.0.0, 3.0.0-beta), so match the family rather than one literal. + if (!(type.GetString() ?? string.Empty) + .StartsWith("PackageBaseAddress", StringComparison.OrdinalIgnoreCase)) continue; + if (!resource.TryGetProperty("@id", out var id) || + id.ValueKind != JsonValueKind.String) continue; + + var value = id.GetString(); + if (string.IsNullOrWhiteSpace(value)) continue; + return value!.EndsWith('/') ? value : value + "/"; + } + } + catch (JsonException) + { + // Not a service index — fall back to the public flat container. + } + return null; + } + /// /// True when the template *package* is registered with the `dotnet new` /// engine. Returns null when the installed-package list could not be @@ -286,17 +330,23 @@ internal static string RedactSource(string source) /// /// Working directory for the `dotnet` process. /// - /// Extra NuGet source. A local folder holding the nupkg is fully supported. A feed - /// URL is passed to `dotnet new install --add-source`, but version *resolution* only - /// reads local folders, so a URL source requires an explicit . + /// A local folder holding the nupkg, passed to `dotnet new install --add-source`. + /// Feed URLs are rejected: `dotnet new install` has no feed-isolation switch + /// (`--add-source` only adds to the configured sources), so a URL source can be + /// silently satisfied from another feed — often under the very same version string. /// /// Explicit version to pin. When omitted the newest published version is resolved. + /// + /// NuGet v3 service-index URL used for version *resolution* only (never passed + /// to `dotnet new install`). bootstrap supplies the clone's configured feed so + /// a machine that cannot reach nuget.org still resolves a version. + /// /// /// Returns what actually happened rather than a bare exit code: "kept the /// existing install because nothing could be resolved" is a success for /// exit-code purposes but must not be reported to the user as "installed". /// - public static InstallOutcome Install(string workingDirectory, string? source = null, string? version = null) + public static InstallOutcome Install(string workingDirectory, string? source = null, string? version = null, string? feed = null) { var hasSource = !string.IsNullOrWhiteSpace(source); var pinned = !string.IsNullOrWhiteSpace(version); @@ -341,13 +391,13 @@ public static InstallOutcome Install(string workingDirectory, string? source = n // which is treated as unverified — never destructive on a maybe. With a // source, the source is a local folder (URL sources are rejected above), // so its listing is authoritative: absent really means absent. - var available = ResolveAvailableVersions(source); + var available = ResolveAvailableVersions(source, feed); targetExists = available is not null && available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); } else { - target = ResolveLatestVersion(source); + target = ResolveLatestVersion(source, feed); // A resolved target came out of the feed listing, so it exists by construction. targetExists = target is not null; } @@ -435,20 +485,65 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string /// not be obtained (offline, unreachable feed). Null means "couldn't tell" and /// must never be read as "the version is absent". /// - internal static IReadOnlyList? ResolveAvailableVersions(string? source = null) + /// Local folder of nupkgs, read instead of any feed. + /// + /// NuGet v3 service-index URL to enumerate from. Supplied by bootstrap when the + /// clone is configured against a mirror, so a machine that cannot reach + /// nuget.org still resolves a version instead of falling back to a bare package + /// id that cannot reach this prerelease-only pack. + /// + internal static IReadOnlyList? ResolveAvailableVersions(string? source = null, string? feed = null) { // A local folder source is the unpublished-build test path: read the // versions straight off the nupkg filenames rather than hitting NuGet. if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) return EnumerateLocalVersions(source!); + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + + if (!string.IsNullOrWhiteSpace(feed)) + { + var versions = TryEnumerateFromFeed(http, feed!); + if (versions is not null) return versions; + Console.Error.WriteLine( + $" warning: could not enumerate {PackageId} from '{RedactSource(feed!)}'; " + + $"falling back to nuget.org."); + } + + return TryGetVersions(http, FlatContainerIndexUrl); + } + + /// + /// Resolves a service index to its flat container and lists the pack's versions + /// there. Null on any failure, so the caller can fall back. + /// + static IReadOnlyList? TryEnumerateFromFeed(HttpClient http, string serviceIndexUrl) + { + string? indexJson; + try + { + indexJson = http.GetStringAsync(serviceIndexUrl).GetAwaiter().GetResult(); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException or InvalidOperationException) + { + return null; + } + + var baseAddress = ParsePackageBaseAddress(indexJson); + if (baseAddress is null) return null; + + // Flat-container paths are lowercase. + return TryGetVersions(http, $"{baseAddress}{PackageId.ToLowerInvariant()}/index.json"); + } + + static IReadOnlyList? TryGetVersions(HttpClient http, string flatContainerIndexUrl) + { try { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; - var json = http.GetStringAsync(FlatContainerIndexUrl).GetAwaiter().GetResult(); + var json = http.GetStringAsync(flatContainerIndexUrl).GetAwaiter().GetResult(); return PackLocalCommand.ParseFlatContainerVersions(json); } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException or InvalidOperationException) { Console.Error.WriteLine( $" warning: could not query NuGet for {PackageId} versions " + @@ -462,9 +557,9 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string /// otherwise the newest prerelease. Returns null when nothing could be /// resolved (offline, unreachable feed, empty folder). /// - public static string? ResolveLatestVersion(string? source = null) + public static string? ResolveLatestVersion(string? source = null, string? feed = null) { - var versions = ResolveAvailableVersions(source); + var versions = ResolveAvailableVersions(source, feed); return versions is null ? null : SelectPreferStable(versions); } diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index f89cd3fa5..d9bc928c5 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -52,13 +52,22 @@ public static int Run(string[] args) // install-if-missing, not a reinstall. Best-effort: a developer who // scaffolds by hand shouldn't have `mur upgrade` fail on a NuGet hiccup. Console.WriteLine(); - var templateSource = ParseFlag(args, "--templates-source"); - var templateVersion = ParseFlag(args, "--templates-version"); + var templateSource = ParseFlag(args, "--templates-source", out var sourceMissingValue); + var templateVersion = ParseFlag(args, "--templates-version", out var versionMissingValue); + var templateFeed = ParseFlag(args, "--templates-feed", out var feedMissingValue); + if (sourceMissingValue || versionMissingValue || feedMissingValue) + { + var flag = sourceMissingValue ? "--templates-source" + : versionMissingValue ? "--templates-version" + : "--templates-feed"; + Console.Error.WriteLine($"mur upgrade: '{flag}' requires a value."); + return 1; + } Console.WriteLine($"==> Checking `dotnet new {WinAppSdkTemplates.BlankShortName}` templates ({WinAppSdkTemplates.PackageId})"); // Install() is a no-op when the resolved version is already installed, and // deliberately leaves an existing install alone when it can't resolve a // newer one — so this is safe to run on every upgrade. - var templateOutcome = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion); + var templateOutcome = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion, templateFeed); if (templateOutcome == WinAppSdkTemplates.InstallOutcome.Failed) { // Best-effort when it's the routine refresh — a NuGet hiccup shouldn't fail @@ -286,12 +295,25 @@ static void TryReinstallVsExtension(string repoRoot) return null; } - static string? ParseFlag(string[] args, string name) + /// + /// Value of in , or null when + /// absent. Sets when the flag is present as + /// the final argument: scanning to args.Length - 1 would otherwise + /// silently ignore it and run the unpinned path, reporting success for work + /// the caller did not ask for. + /// + static string? ParseFlag(string[] args, string name, out bool missingValue) { - for (var i = 0; i < args.Length - 1; i++) + missingValue = false; + for (var i = 0; i < args.Length; i++) { - if (string.Equals(args[i], name, StringComparison.Ordinal)) - return args[i + 1]; + if (!string.Equals(args[i], name, StringComparison.Ordinal)) continue; + if (i + 1 >= args.Length) + { + missingValue = true; + return null; + } + return args[i + 1]; } return null; } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 1abe2683c..b08b45ac3 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -367,6 +367,54 @@ public void RedactSource_strips_a_credential_bearing_fragment() Assert.DoesNotContain("PAT", redacted, StringComparison.Ordinal); } + [Fact] + public void ParsePackageBaseAddress_reads_the_flat_container_from_a_service_index() + { + // The service-index host generally has no /flatcontainer/ path of its own, + // so guessing one 404s for every package — including ones that exist. The + // base address has to come out of the index. + const string serviceIndex = """ + { + "version": "3.0.0", + "resources": [ + { "@id": "https://example.com/query", "@type": "SearchQueryService/3.0.0" }, + { "@id": "https://ms-feed-25.example.com/_packaging/x/nuget/v3/flat2", "@type": "PackageBaseAddress/3.0.0" } + ] + } + """; + + Assert.Equal( + "https://ms-feed-25.example.com/_packaging/x/nuget/v3/flat2/", + WinAppSdkTemplates.ParsePackageBaseAddress(serviceIndex)); + } + + [Fact] + public void ParsePackageBaseAddress_returns_null_when_no_flat_container_is_declared() + { + // Must be null, not a guessed URL: the caller falls back to nuget.org, and + // a fabricated address would instead 404 and read as "version absent". + const string serviceIndex = """ + {"version":"3.0.0","resources":[{"@id":"https://example.com/query","@type":"SearchQueryService/3.0.0"}]} + """; + + Assert.Null(WinAppSdkTemplates.ParsePackageBaseAddress(serviceIndex)); + Assert.Null(WinAppSdkTemplates.ParsePackageBaseAddress("not json at all")); + } + + [Fact] + public void Bootstrap_passes_its_configured_feed_to_the_version_resolver() + { + // The resolver otherwise only knows nuget.org. On a machine that reaches + // the configured mirror but not nuget.org it would resolve nothing and fall + // back to a bare package id, which cannot reach a prerelease-only pack — + // failing the step with a usable feed sitting right there. + var (path, text) = ReadRepoFile("bootstrap.ps1"); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch( + text.Replace("\r\n", "\n"), @"'--feed',\s*\$effectiveNuGetSource"), + $"'{path}' must pass the resolved NuGet source to `mur templates install --feed`."); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had From a8cdef8f6f9b8008cec73d9b5aa41cd14fdf8f77 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:26:33 -0700 Subject: [PATCH 11/37] Use Path.Join in the touched test files Code-quality review flagged every Path.Combine in the files this PR touches: Combine silently discards everything before a rooted later argument, which is never what these calls want. Path.Join concatenates unconditionally and is identical for the relative segments used here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Packaging/LocalPackageFeedFixture.cs | 14 +++++----- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 26 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs index d45db7645..0dfcf0b4c 100644 --- a/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs +++ b/tests/Reactor.IntegrationTests/Packaging/LocalPackageFeedFixture.cs @@ -15,7 +15,7 @@ namespace Microsoft.UI.Reactor.IntegrationTests.Packaging; public sealed class LocalPackageFeedFixture : IDisposable { - private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), $"reactor-local-feed-{Guid.NewGuid():N}"); + private readonly string _tempRoot = Path.Join(Path.GetTempPath(), $"reactor-local-feed-{Guid.NewGuid():N}"); public LocalPackageFeedFixture() { @@ -32,7 +32,7 @@ public LocalPackageFeedFixture() CommandEnvironment = CreateCommandEnvironment(dotnetCliHomeDir, nugetHttpCacheDir); RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "src", "Reactor", "Reactor.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", + $"pack \"{Path.Join(RepoRoot, "src", "Reactor", "Reactor.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", RepoRoot, CommandEnvironment, timeoutMs: 300_000); @@ -40,12 +40,12 @@ public LocalPackageFeedFixture() // transitively, so pack all three at the same version to keep a restore // from falling through to NuGet.org and hitting an NU1605 downgrade. RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "src", "Reactor.Devtools", "Reactor.Devtools.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", + $"pack \"{Path.Join(RepoRoot, "src", "Reactor.Devtools", "Reactor.Devtools.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", RepoRoot, CommandEnvironment, timeoutMs: 300_000); RunHelpers.RunDotnet( - $"pack \"{Path.Combine(RepoRoot, "src", "Reactor.Advanced", "Reactor.Advanced.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", + $"pack \"{Path.Join(RepoRoot, "src", "Reactor.Advanced", "Reactor.Advanced.csproj")}\" --no-restore --configuration Release -o \"{PackageSourceDir}\" -p:Version={PackageVersion}", RepoRoot, CommandEnvironment, timeoutMs: 300_000); @@ -57,7 +57,7 @@ public LocalPackageFeedFixture() private static string FindPackage(string packageSourceDir, string packageId, string version) { - var packagePath = Path.Combine(packageSourceDir, $"{packageId}.{version}.nupkg"); + var packagePath = Path.Join(packageSourceDir, $"{packageId}.{version}.nupkg"); Assert.True(File.Exists(packagePath), $"Expected package '{packagePath}' to exist."); return packagePath; } @@ -104,7 +104,7 @@ public void Dispose() private string CreateDirectory(string name) { - var path = Path.Combine(_tempRoot, name); + var path = Path.Join(_tempRoot, name); Directory.CreateDirectory(path); return path; } @@ -112,7 +112,7 @@ private string CreateDirectory(string name) private static string FindRepoRoot() { var dir = AppContext.BaseDirectory; - while (dir != null && !File.Exists(Path.Combine(dir, "Reactor.slnx"))) + while (dir != null && !File.Exists(Path.Join(dir, "Reactor.slnx"))) { dir = Path.GetDirectoryName(dir); } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index b08b45ac3..0331e55d1 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -99,17 +99,17 @@ public void EnumerateLocalVersions_reads_versions_off_nupkg_filenames() // The `-WinAppSdkTemplatesSource ` path used to test an // unpublished build of the pack: resolution reads the folder rather // than querying NuGet. - var dir = global::System.IO.Path.Combine( + var dir = global::System.IO.Path.Join( global::System.IO.Path.GetTempPath(), $"wasdk-templates-{Guid.NewGuid():N}"); global::System.IO.Directory.CreateDirectory(dir); try { var id = WinAppSdkTemplates.PackageId; - global::System.IO.File.WriteAllText(global::System.IO.Path.Combine(dir, $"{id}.0.0.6-alpha.nupkg"), ""); - global::System.IO.File.WriteAllText(global::System.IO.Path.Combine(dir, $"{id}.0.0.7-alpha.nupkg"), ""); + global::System.IO.File.WriteAllText(global::System.IO.Path.Join(dir, $"{id}.0.0.6-alpha.nupkg"), ""); + global::System.IO.File.WriteAllText(global::System.IO.Path.Join(dir, $"{id}.0.0.7-alpha.nupkg"), ""); // An unrelated package in the same folder must not be picked up. - global::System.IO.File.WriteAllText(global::System.IO.Path.Combine(dir, "Microsoft.UI.Reactor.9.9.9.nupkg"), ""); + global::System.IO.File.WriteAllText(global::System.IO.Path.Join(dir, "Microsoft.UI.Reactor.9.9.9.nupkg"), ""); var versions = WinAppSdkTemplates.EnumerateLocalVersions(dir); @@ -129,7 +129,7 @@ public void EnumerateLocalVersions_reads_versions_off_nupkg_filenames() [Fact] public void EnumerateLocalVersions_returns_empty_for_a_missing_folder() { - var missing = global::System.IO.Path.Combine( + var missing = global::System.IO.Path.Join( global::System.IO.Path.GetTempPath(), $"wasdk-templates-missing-{Guid.NewGuid():N}"); @@ -153,7 +153,7 @@ public void ResolveLatestVersion_returns_null_for_an_empty_local_source() // This is the input that produced the destructive case: nothing resolvable. // Returning null is what lets Install() choose the non-destructive branch, // so a null here is load-bearing, not an edge case. - var dir = global::System.IO.Path.Combine( + var dir = global::System.IO.Path.Join( global::System.IO.Path.GetTempPath(), $"wasdk-templates-empty-{Guid.NewGuid():N}"); global::System.IO.Directory.CreateDirectory(dir); @@ -469,7 +469,7 @@ public void Doctor_probes_template_availability_not_just_package_presence() // Source-level guard on the call site. The whole point of the fix is // that DoctorCommand asks "can the user scaffold?" — if it reverts to // the package-id probe for its PASS branch, the false PASS returns. - var (path, text) = ReadRepoFile(global::System.IO.Path.Combine( + var (path, text) = ReadRepoFile(global::System.IO.Path.Join( "src", "Reactor.Cli", "Doctor", "DoctorCommand.cs")); Assert.Contains("AreTemplatesAvailable()", text, StringComparison.Ordinal); } @@ -563,17 +563,17 @@ public void Repo_no_longer_ships_the_in_repo_template_package() var root = FindRoot(); foreach (var relative in new[] { - global::System.IO.Path.Combine("tools", "Templates", "Microsoft.UI.Reactor.Templates.csproj"), - global::System.IO.Path.Combine("tools", "Templates", "templates", "WinUIApp-CSharp", ".template.config", "template.json"), + global::System.IO.Path.Join("tools", "Templates", "Microsoft.UI.Reactor.Templates.csproj"), + global::System.IO.Path.Join("tools", "Templates", "templates", "WinUIApp-CSharp", ".template.config", "template.json"), }) { Assert.False( - global::System.IO.File.Exists(global::System.IO.Path.Combine(root, relative)), + global::System.IO.File.Exists(global::System.IO.Path.Join(root, relative)), $"'{relative}' is back. The in-repo Microsoft.UI.Reactor.ProjectTemplates package was removed " + "in favour of the Windows App SDK `dotnet new reactor` templates."); } - var (relPath, release) = ReadRepoFile(global::System.IO.Path.Combine(".github", "workflows", "release.yml")); + var (relPath, release) = ReadRepoFile(global::System.IO.Path.Join(".github", "workflows", "release.yml")); Assert.False( release.Contains("Microsoft.UI.Reactor.Templates.csproj", StringComparison.Ordinal), $"'{relPath}' packs the removed template project again."); @@ -581,7 +581,7 @@ public void Repo_no_longer_ships_the_in_repo_template_package() static (string path, string text) ReadRepoFile(string repoRelativePath) { - var path = global::System.IO.Path.Combine(FindRoot(), repoRelativePath); + var path = global::System.IO.Path.Join(FindRoot(), repoRelativePath); Assert.True(global::System.IO.File.Exists(path), $"Expected '{path}' to exist; file moved or removed?"); return (path, global::System.IO.File.ReadAllText(path)); } @@ -589,7 +589,7 @@ public void Repo_no_longer_ships_the_in_repo_template_package() static string FindRoot() { var dir = AppContext.BaseDirectory; - while (dir != null && !global::System.IO.File.Exists(global::System.IO.Path.Combine(dir, "Reactor.slnx"))) + while (dir != null && !global::System.IO.File.Exists(global::System.IO.Path.Join(dir, "Reactor.slnx"))) dir = global::System.IO.Path.GetDirectoryName(dir); Assert.NotNull(dir); return dir!; From 08f713752120148074aac785e1227459a41de2b7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:44:59 -0700 Subject: [PATCH 12/37] Validate the version feed, and derive one from an explicit NuGet config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 5. The `--feed` URL went straight to HttpClient without the feed-URL policy the rest of the repo applies to `-NuGetSource` (tools/BootstrapFeedResolver.ps1). Version metadata is what selects the package to install, so fetching it over plaintext lets a network attacker choose the version, and credentials in the URL would be sent to whatever endpoint it names. IsAllowedFeedUrl now mirrors Test-ReactorPackageFeedUrl: HTTPS, or HTTP only for loopback, and no user-info/query/fragment. A rejected feed warns and falls back to nuget.org rather than failing the install. bootstrap only supplied `--feed` for a *detected* source. An explicit `-NuGetConfig` reaches restore as `--configfile` and yields no bare URL, so the explicit-mirror path still resolved against nuget.org only — the exact case the feed support was added for. Get-ReactorFeedSourceFromConfig reads the first enabled feed URL out of that config (preferring packagefeedproxy, matching the detector's order) and bootstrap passes it. `mur templates install` printed four `dotnet new` scaffold lines unconditionally on success. KeptExisting is a success that means resolution failed and an older pack was left in place — and 0.0.6-alpha shipped without the Reactor templates, so those lines would immediately fail. The command now verifies the short name resolves before printing them, and exits non-zero with remediation when it doesn't. Docs: the MSIX row's "(template default)" label attached generic signed-MSIX publish properties to the scaffold, which doesn't set them. The default is now stated in prose — scaffolded apps are packaged via Package.appxmanifest and EnableMsixTooling, while producing and signing a redistributable .msix is a separate publish concern. Also drops a stale `` left by that class's deletion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 14 ++++-- docs/_pipeline/templates/packaging.md.dt | 10 +++- docs/guide/packaging.md | 10 +++- src/Reactor.Cli/Templates/TemplatesCommand.cs | 26 ++++++++-- .../Templates/WinAppSdkTemplates.cs | 48 ++++++++++++++++-- .../SourceMapPackageConsumerTests.cs | 7 ++- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 46 ++++++++++++++++- tools/BootstrapFeedResolver.ps1 | 50 +++++++++++++++++++ 8 files changed, 191 insertions(+), 20 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 7e068d53d..92106b010 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -714,9 +714,17 @@ if ($SkipTemplates) { # reaches the mirror but not nuget.org it resolves nothing and falls back to a # bare package id — which cannot reach a prerelease-only pack, failing the step # even though a usable feed was right there. - if ($effectiveNuGetSource) { - Write-Dbg "Template version feed: $effectiveNuGetSource" - $murTemplateArgs += @('--feed', $effectiveNuGetSource) + # + # An explicitly selected -NuGetConfig reaches restore as `--configfile`, so it + # never produces a bare source URL; read one out of the config so the explicit + # path gets the same treatment as a detected one. + $templateFeed = $effectiveNuGetSource + if (-not $templateFeed -and $effectiveNuGetConfig) { + $templateFeed = Get-ReactorFeedSourceFromConfig -ConfigPath $effectiveNuGetConfig + } + if ($templateFeed) { + Write-Dbg "Template version feed: $templateFeed" + $murTemplateArgs += @('--feed', $templateFeed) } $templatesExit = 0 diff --git a/docs/_pipeline/templates/packaging.md.dt b/docs/_pipeline/templates/packaging.md.dt index df797f61b..c36a357f9 100644 --- a/docs/_pipeline/templates/packaging.md.dt +++ b/docs/_pipeline/templates/packaging.md.dt @@ -33,10 +33,18 @@ INPC walker). | Publish shape | Key properties | Runtime identifier | What you get | |---|---|---|---| | Unpackaged | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | -| MSIX (template default) | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | +| MSIX | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | | Single-file | `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true` | `win-x64` / `win-arm64` (must be set) | One `.exe` that self-extracts the WinUI runtime to `%TEMP%/.net/` on first launch. | | Native AOT | `PublishAot=true`, `InvariantGlobalization=true` (recommended) | `win-x64` / `win-arm64` (required) | A native binary with no JIT, no `Assembly.GetTypes()`, no `Reflection.Emit`. Fastest cold start; trim-only. | +A scaffolded app starts out **packaged**: `dotnet new reactor` emits a +`Package.appxmanifest` and sets `EnableMsixTooling`, so `dotnet run` +registers a loose-layout package and the app has identity from the +first launch. The properties in the MSIX row above are the *publish* +knobs for producing and signing a redistributable `.msix` — a separate +concern the scaffold deliberately leaves off, since a fresh clone +should build without a developer certificate. + The four shapes are not mutually exclusive — MSIX wraps any of the three publish outputs, and AOT layers on top of either an unpackaged folder or an MSIX. The decision is usually distribution-channel-first diff --git a/docs/guide/packaging.md b/docs/guide/packaging.md index ac2fe46e6..d3d89de3d 100644 --- a/docs/guide/packaging.md +++ b/docs/guide/packaging.md @@ -18,10 +18,18 @@ INPC walker). | Publish shape | Key properties | Runtime identifier | What you get | |---|---|---|---| | Unpackaged | `WindowsPackageType=None`, `WindowsAppSDKSelfContained=true` | `win-x64` / `win-arm64` | A folder with `MyApp.exe` and the WinUI 3 runtime alongside it. Run from anywhere; ship as a zip. | -| MSIX (template default) | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | +| MSIX | `WindowsPackageType=MSIX`, `GenerateAppxPackageOnBuild=true`, signed via `PackageCertificateThumbprint` or `PackageCertificateKeyFile` | `win-x64` / `win-arm64` | A signed `.msix`. Required for Microsoft Store; the cleanest sideload story for enterprise. | | Single-file | `PublishSingleFile=true`, `IncludeNativeLibrariesForSelfExtract=true` | `win-x64` / `win-arm64` (must be set) | One `.exe` that self-extracts the WinUI runtime to `%TEMP%/.net/` on first launch. | | Native AOT | `PublishAot=true`, `InvariantGlobalization=true` (recommended) | `win-x64` / `win-arm64` (required) | A native binary with no JIT, no `Assembly.GetTypes()`, no `Reflection.Emit`. Fastest cold start; trim-only. | +A scaffolded app starts out **packaged**: `dotnet new reactor` emits a +`Package.appxmanifest` and sets `EnableMsixTooling`, so `dotnet run` +registers a loose-layout package and the app has identity from the +first launch. The properties in the MSIX row above are the *publish* +knobs for producing and signing a redistributable `.msix` — a separate +concern the scaffold deliberately leaves off, since a fresh clone +should build without a developer certificate. + The four shapes are not mutually exclusive — MSIX wraps any of the three publish outputs, and AOT layers on top of either an unpackaged folder or an MSIX. The decision is usually distribution-channel-first diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index f1da3fb94..425735ac5 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -96,10 +96,28 @@ static int Install(string[] args) Console.WriteLine(); Console.WriteLine(DescribeOutcome(outcome)); - Console.WriteLine("Scaffold an app with:"); - foreach (var name in WinAppSdkTemplates.ShortNames) - Console.WriteLine($" dotnet new {name} -n MyApp"); - return 0; + // "Installed" is not "usable". KeptExisting in particular means version + // resolution failed and an older pack was left alone — and 0.0.6-alpha + // shipped without the Reactor templates, so printing scaffold commands + // here would hand the user four lines that immediately fail. Print them + // only once the short name actually resolves. + var available = WinAppSdkTemplates.AreTemplatesAvailable(); + if (available == true) + { + Console.WriteLine("Scaffold an app with:"); + foreach (var name in WinAppSdkTemplates.ShortNames) + Console.WriteLine($" dotnet new {name} -n MyApp"); + return 0; + } + + Console.Error.WriteLine(); + Console.Error.WriteLine(available is null + ? $"mur templates install: could not enumerate `dotnet new` templates, so `dotnet new " + + $"{WinAppSdkTemplates.BlankShortName}` is unverified. Check with `mur templates status`." + : $"mur templates install: {WinAppSdkTemplates.PackageId} is installed but does not provide " + + $"`dotnet new {WinAppSdkTemplates.BlankShortName}` — that version predates the Reactor " + + $"templates. Pin a newer one with `mur templates install --version `."); + return 1; } static int Status() diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 1dcbe3165..0496ed4f9 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -480,6 +480,35 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string return installed is null ? InstallOutcome.Installed : InstallOutcome.Updated; } + /// + /// Whether a NuGet service-index URL is safe to query: HTTPS (or loopback + /// HTTP), with no credentials in the user-info, query string, or fragment. + /// + /// + /// Mirrors Test-ReactorPackageFeedUrl in tools/BootstrapFeedResolver.ps1, + /// which applies the same policy to -NuGetSource. Version metadata is + /// what picks the package to install, so fetching it over plaintext lets a + /// network attacker choose the version; and a credential in the URL would be + /// sent to whatever endpoint the URL names. + /// + internal static bool IsAllowedFeedUrl(string? feed) + { + if (string.IsNullOrWhiteSpace(feed)) return false; + if (!Uri.TryCreate(feed, UriKind.Absolute, out var uri)) return false; + + if (!string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment)) + return false; + + if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return true; + + // Plaintext only where it cannot leave the machine. + return string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + uri.IsLoopback; + } + /// /// Every version the configured source offers, or null when the listing could /// not be obtained (offline, unreachable feed). Null means "couldn't tell" and @@ -503,11 +532,20 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string if (!string.IsNullOrWhiteSpace(feed)) { - var versions = TryEnumerateFromFeed(http, feed!); - if (versions is not null) return versions; - Console.Error.WriteLine( - $" warning: could not enumerate {PackageId} from '{RedactSource(feed!)}'; " + - $"falling back to nuget.org."); + if (!IsAllowedFeedUrl(feed)) + { + Console.Error.WriteLine( + $" warning: ignoring --feed '{RedactSource(feed!)}' — a version feed must be an HTTPS URL " + + $"(or loopback HTTP) with no credentials in its user-info, query string or fragment."); + } + else + { + var versions = TryEnumerateFromFeed(http, feed!); + if (versions is not null) return versions; + Console.Error.WriteLine( + $" warning: could not enumerate {PackageId} from '{RedactSource(feed!)}'; " + + $"falling back to nuget.org."); + } } return TryGetVersions(http, FlatContainerIndexUrl); diff --git a/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs b/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs index 054b72d96..ba55fac4e 100644 --- a/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs +++ b/tests/Reactor.IntegrationTests/Packaging/SourceMapPackageConsumerTests.cs @@ -17,10 +17,9 @@ namespace Microsoft.UI.Reactor.IntegrationTests.Packaging; /// Debug-only check passes if the generator is unconditionally on, and a Release-only /// check passes if it is unconditionally off. /// -/// Like its sibling this needs network access to -/// restore the Windows App SDK, so it only runs where NuGet.org is reachable (CI's -/// "Integration Tests" job). On a network-restricted machine both fail identically with -/// NU1301 during restore. +/// This needs network access to restore the Windows App SDK, so it only runs +/// where NuGet.org is reachable (CI's "Integration Tests" job). On a +/// network-restricted machine it fails with NU1301 during restore. /// [Collection(LocalPackageFeedCollection.Name)] public sealed class SourceMapPackageConsumerTests : IDisposable diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 0331e55d1..5f12512be 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -409,10 +409,52 @@ public void Bootstrap_passes_its_configured_feed_to_the_version_resolver() // back to a bare package id, which cannot reach a prerelease-only pack — // failing the step with a usable feed sitting right there. var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch(normalized, @"\$templateFeed\s*=\s*\$effectiveNuGetSource"), + $"'{path}' must seed the template version feed from the resolved NuGet source."); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch(normalized, @"'--feed',\s*\$templateFeed"), + $"'{path}' must pass that feed to `mur templates install --feed`."); + } + + [Theory] + // Version metadata is what picks the package to install, so plaintext lets a + // network attacker choose the version; credentials in the URL would be sent to + // whatever endpoint the URL names. + [InlineData("https://pkgs.example.com/v3/index.json", true)] + [InlineData("http://localhost:5000/v3/index.json", true)] + [InlineData("http://127.0.0.1:5000/v3/index.json", true)] + [InlineData("http://pkgs.example.com/v3/index.json", false)] + [InlineData("https://user:pat@pkgs.example.com/v3/index.json", false)] + [InlineData("https://pkgs.example.com/v3/index.json?api-key=SECRET", false)] + [InlineData("https://pkgs.example.com/v3/index.json#SECRET", false)] + [InlineData("ftp://pkgs.example.com/v3/index.json", false)] + [InlineData("not a url", false)] + [InlineData("", false)] + public void IsAllowedFeedUrl_matches_the_bootstrap_feed_policy(string feed, bool allowed) + { + Assert.Equal(allowed, WinAppSdkTemplates.IsAllowedFeedUrl(feed)); + } + + [Fact] + public void Bootstrap_derives_a_version_feed_from_an_explicit_nuget_config() + { + // An explicit -NuGetConfig reaches restore as `--configfile`, so it never + // produces a bare source URL. Without this the explicit-mirror path falls + // back to nuget.org for version lookup and resolves nothing on a machine + // that can only reach the mirror. + var (path, text) = ReadRepoFile("bootstrap.ps1"); Assert.True( global::System.Text.RegularExpressions.Regex.IsMatch( - text.Replace("\r\n", "\n"), @"'--feed',\s*\$effectiveNuGetSource"), - $"'{path}' must pass the resolved NuGet source to `mur templates install --feed`."); + text.Replace("\r\n", "\n"), + @"Get-ReactorFeedSourceFromConfig\s+-ConfigPath\s+\$effectiveNuGetConfig"), + $"'{path}' must read a version feed out of an explicitly selected NuGet config."); + + var (resolverPath, resolver) = ReadRepoFile(global::System.IO.Path.Join("tools", "BootstrapFeedResolver.ps1")); + Assert.True( + resolver.Contains("function Get-ReactorFeedSourceFromConfig", StringComparison.Ordinal), + $"'{resolverPath}' must define Get-ReactorFeedSourceFromConfig."); } // ── False-PASS guard: "pack installed" != "templates usable" ─────────── diff --git a/tools/BootstrapFeedResolver.ps1 b/tools/BootstrapFeedResolver.ps1 index 35d623a08..b3d6c337c 100644 --- a/tools/BootstrapFeedResolver.ps1 +++ b/tools/BootstrapFeedResolver.ps1 @@ -161,6 +161,56 @@ function Resolve-ReactorNuGetFeed { # # Returns $null when nothing is configured, which is the public-contributor # path: no restore override, repo nuget.config stays in effect. +function Get-ReactorFeedSourceFromConfig { + <# + .SYNOPSIS + First usable package-feed URL declared by a NuGet.config. + + .DESCRIPTION + An explicitly selected config is passed to restore as `--configfile`, so + its sources never surface as a bare URL. Version *lookup* for the Windows + App SDK template pack needs one, though: without it the resolver only + knows nuget.org, and a machine that reaches the configured mirror but not + nuget.org resolves nothing. + + Prefers packagefeedproxy.microsoft.io when the config lists it, matching + Resolve-ReactorNuGetFeed's detection order; otherwise takes the first + enabled source that passes the feed-URL policy. Returns $null when the + config declares none (an all-local-folder config, say). + #> + param( + [Parameter(Mandatory)][string]$ConfigPath + ) + + if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { return $null } + + try { + [xml]$xml = Get-Content -LiteralPath $ConfigPath -Raw + } catch { + return $null + } + + $disabled = @{} + foreach ($entry in @($xml.SelectNodes('//disabledPackageSources/add'))) { + if ([string]$entry.value -eq 'true') { $disabled[[string]$entry.key] = $true } + } + + $candidates = New-Object System.Collections.Generic.List[string] + foreach ($source in @($xml.SelectNodes('//packageSources/add'))) { + if ($disabled.ContainsKey([string]$source.key)) { continue } + $value = [string]$source.value + if (-not (Test-ReactorPackageFeedUrl $value)) { continue } + $candidates.Add($value.Trim().TrimEnd('/')) + } + + if ($candidates.Count -eq 0) { return $null } + + foreach ($candidate in $candidates) { + if (([Uri]$candidate).Host -eq 'packagefeedproxy.microsoft.io') { return $candidate } + } + return $candidates[0] +} + function Resolve-ReactorNuGetFeedOverride { param( [string]$NuGetConfig, From f79ec5e325449997354dc82f78412ec49b5ac93b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:48:24 -0700 Subject: [PATCH 13/37] Filter the service-index resources with Where Code-quality review: the loop implicitly filtered to object elements in its body. Behaviour and defensive checks are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- src/Reactor.Cli/Templates/WinAppSdkTemplates.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 0496ed4f9..2bb9fbf84 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -78,9 +78,8 @@ public static class WinAppSdkTemplates resources.ValueKind != JsonValueKind.Array) return null; - foreach (var resource in resources.EnumerateArray()) + foreach (var resource in resources.EnumerateArray().Where(r => r.ValueKind == JsonValueKind.Object)) { - if (resource.ValueKind != JsonValueKind.Object) continue; if (!resource.TryGetProperty("@type", out var type) || type.ValueKind != JsonValueKind.String) continue; // The version suffix has moved across service-index revisions From 58171c778166632fab6ea8c5d5c4ea3e9ceb21b5 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:02:07 -0700 Subject: [PATCH 14/37] Revalidate the advertised base address, and test the config feed extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 6. The service-index URL was validated, but the PackageBaseAddress it advertises was then followed without any check. A compromised or misconfigured HTTPS index could point it at plaintext HTTP, or at a URL carrying credentials — and that second request is the one that fetches the version list, which is what selects the package to install. The parsed base address now goes through the same policy, and a rejected one falls back to nuget.org. Get-ReactorFeedSourceFromConfig was only covered by source-text assertions ("the function exists", "it is called"), which would survive any XPath, disabled-source, or preference bug — and the failure is silent, sending version lookup back to nuget.org on a mirror-only machine. Added behavioural cases to BootstrapFeedResolver.Tests.ps1: mirror preference with a local folder and a trailing slash, a disabled preferred mirror falling through, a config whose only URLs fail the policy, malformed XML, and a missing file. Mutation-checked both filters — removing the disabled-source check or the URL policy reddens the matching case and nothing else. bootstrap's completion guidance advertised `dotnet new reactor` even under -SkipTemplates, which deliberately leaves the pack uninstalled. It now points at `mur templates install` in that case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 21 +++++-- .../Templates/WinAppSdkTemplates.cs | 13 ++++ .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 32 ++++++++++ .../ci/BootstrapFeedResolver.Tests.ps1 | 60 +++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 92106b010..d52f29b56 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -944,12 +944,21 @@ Write-Host '' Write-Host 'Bootstrap complete.' -ForegroundColor Green Write-Host '' Write-Host 'Next:' -Write-Host ' dotnet new reactor -n MyApp' -Write-Host ' cd MyApp' -Write-Host ' dotnet run' -Write-Host '' -Write-Host 'Other Reactor templates: reactor-mvu, reactor-navview, reactor-tabview' -Write-Host ' dotnet new list reactor' +if ($SkipTemplates) { + # Advertising `dotnet new reactor` here would be a false promise: this run + # deliberately did not install the pack, so the command may not resolve. + Write-Host ' Template install was skipped (-SkipTemplates).' + Write-Host ' To scaffold an app, install the pack first:' + Write-Host ' mur templates install' + Write-Host ' dotnet new reactor -n MyApp' +} else { + Write-Host ' dotnet new reactor -n MyApp' + Write-Host ' cd MyApp' + Write-Host ' dotnet run' + Write-Host '' + Write-Host 'Other Reactor templates: reactor-mvu, reactor-navview, reactor-tabview' + Write-Host ' dotnet new list reactor' +} Write-Host '' Write-Host 'Other useful commands:' Write-Host ' mur doctor verify your install' diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 2bb9fbf84..a9de8dc6b 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -569,6 +569,19 @@ internal static bool IsAllowedFeedUrl(string? feed) var baseAddress = ParsePackageBaseAddress(indexJson); if (baseAddress is null) return null; + // The index is fetched from a validated URL, but what it *advertises* is + // not covered by that check: a compromised or misconfigured index can point + // PackageBaseAddress at plaintext HTTP, or at a URL carrying credentials. + // Re-apply the policy before following it. + if (!IsAllowedFeedUrl(baseAddress)) + { + Console.Error.WriteLine( + $" warning: ignoring the package base address advertised by '{RedactSource(serviceIndexUrl)}' " + + $"— it must be an HTTPS URL (or loopback HTTP) with no credentials in its user-info, " + + $"query string or fragment."); + return null; + } + // Flat-container paths are lowercase. return TryGetVersions(http, $"{baseAddress}{PackageId.ToLowerInvariant()}/index.json"); } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 5f12512be..fea2940a2 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -457,6 +457,38 @@ public void Bootstrap_derives_a_version_feed_from_an_explicit_nuget_config() $"'{resolverPath}' must define Get-ReactorFeedSourceFromConfig."); } + [Theory] + // A validated service index can still *advertise* an unsafe base address, and + // following it would fetch version metadata — the thing that selects the + // package — over plaintext, or send URL credentials to that endpoint. + [InlineData("http://evil.example.com/flat2/", false)] + [InlineData("https://user:pat@feed.example.com/flat2/", false)] + [InlineData("https://feed.example.com/flat2/", true)] + public void IsAllowedFeedUrl_also_gates_an_advertised_base_address(string advertised, bool allowed) + { + var serviceIndex = $$""" + {"version":"3.0.0","resources":[{"@id":"{{advertised}}","@type":"PackageBaseAddress/3.0.0"}]} + """; + + // Parsing is deliberately permissive — the policy check is what stops it. + var parsed = WinAppSdkTemplates.ParsePackageBaseAddress(serviceIndex); + Assert.NotNull(parsed); + Assert.Equal(allowed, WinAppSdkTemplates.IsAllowedFeedUrl(parsed)); + } + + [Fact] + public void Bootstrap_does_not_advertise_dotnet_new_reactor_when_templates_are_skipped() + { + // -SkipTemplates deliberately leaves the pack uninstalled, so printing the + // scaffold command unconditionally promises something that may not resolve. + var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); + var next = normalized[normalized.LastIndexOf("Write-Host 'Next:'", StringComparison.Ordinal)..]; + Assert.True( + next.Contains("if ($SkipTemplates)", StringComparison.Ordinal), + $"'{path}' must gate the `dotnet new reactor` next-step guidance on -SkipTemplates."); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had diff --git a/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 b/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 index 50eaa9927..e2419eaad 100644 --- a/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 +++ b/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 @@ -211,6 +211,66 @@ try { Assert-Throws { Resolve-ReactorNuGetFeed -ExplicitConfig (Join-Path $tmp 'missing.config') } ` 'missing explicit NuGet config is rejected' + # Get-ReactorFeedSourceFromConfig — an explicit -NuGetConfig reaches restore as + # `--configfile` and yields no bare source URL, so the template version lookup + # has to read one out of the config. Exercise the parsing for real: a source-text + # assertion that the function exists would survive any XPath or filtering bug, + # and the failure mode is silent (version lookup falls back to nuget.org, which + # a mirror-only machine cannot reach). + $mirrorConfig = Join-Path $tmp 'mirror.config' + Set-Content $mirrorConfig @' + + + + + + + + +'@ + Assert-Equal 'https://packagefeedproxy.microsoft.io/nuget/v3/index.json' ` + (Get-ReactorFeedSourceFromConfig -ConfigPath $mirrorConfig) ` + 'explicit config prefers the proxy mirror, skips local folders, and trims the trailing slash' + + $disabledConfig = Join-Path $tmp 'disabled.config' + Set-Content $disabledConfig @' + + + + + + + + + + +'@ + Assert-Equal 'https://other.example.test/nuget/v3/index.json' ` + (Get-ReactorFeedSourceFromConfig -ConfigPath $disabledConfig) ` + 'a disabled source is skipped even when it is the preferred mirror' + + $localOnlyConfig = Join-Path $tmp 'local-only.config' + Set-Content $localOnlyConfig @' + + + + + + + + +'@ + Assert-Equal $null (Get-ReactorFeedSourceFromConfig -ConfigPath $localOnlyConfig) ` + 'a config with no policy-passing feed URL yields no version feed' + + $malformedConfig = Join-Path $tmp 'malformed.config' + Set-Content $malformedConfig '' + Assert-Equal $null (Get-ReactorFeedSourceFromConfig -ConfigPath $malformedConfig) ` + 'malformed XML yields no version feed instead of throwing' + + Assert-Equal $null (Get-ReactorFeedSourceFromConfig -ConfigPath (Join-Path $tmp 'nope.config')) ` + 'a missing config yields no version feed' + $restoreArgs = Get-ReactorRestoreArguments ` -NuGetSource 'https://packagefeedproxy.microsoft.io/nuget/v3/index.json' ` -NpmRegistry 'https://packagefeedproxy.microsoft.io/npm' From becbdf95eb20c5ef41acfcf174ed7d640972183a Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:06:12 -0700 Subject: [PATCH 15/37] Express the service-index scan as a LINQ pipeline Code-quality review: the remaining `continue` guards were implicit filters on the enumerated resources. Extracting the per-resource projection into a local function leaves a Where/Select/FirstOrDefault chain with no filtering hidden in a loop body. Behaviour is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index a9de8dc6b..2c4bb9dc0 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -70,6 +70,20 @@ public static class WinAppSdkTemplates /// internal static string? ParsePackageBaseAddress(string serviceIndexJson) { + // The version suffix has moved across service-index revisions (3.0.0, + // 3.0.0-beta), so match the family rather than one literal. + static string? BaseAddressOf(JsonElement resource) + { + if (!resource.TryGetProperty("@type", out var type) || type.ValueKind != JsonValueKind.String) + return null; + if (!(type.GetString() ?? string.Empty) + .StartsWith("PackageBaseAddress", StringComparison.OrdinalIgnoreCase)) + return null; + if (!resource.TryGetProperty("@id", out var id) || id.ValueKind != JsonValueKind.String) + return null; + return id.GetString(); + } + try { using var doc = JsonDocument.Parse(serviceIndexJson); @@ -78,27 +92,19 @@ public static class WinAppSdkTemplates resources.ValueKind != JsonValueKind.Array) return null; - foreach (var resource in resources.EnumerateArray().Where(r => r.ValueKind == JsonValueKind.Object)) - { - if (!resource.TryGetProperty("@type", out var type) || - type.ValueKind != JsonValueKind.String) continue; - // The version suffix has moved across service-index revisions - // (3.0.0, 3.0.0-beta), so match the family rather than one literal. - if (!(type.GetString() ?? string.Empty) - .StartsWith("PackageBaseAddress", StringComparison.OrdinalIgnoreCase)) continue; - if (!resource.TryGetProperty("@id", out var id) || - id.ValueKind != JsonValueKind.String) continue; - - var value = id.GetString(); - if (string.IsNullOrWhiteSpace(value)) continue; - return value!.EndsWith('/') ? value : value + "/"; - } + var value = resources.EnumerateArray() + .Where(resource => resource.ValueKind == JsonValueKind.Object) + .Select(BaseAddressOf) + .FirstOrDefault(address => !string.IsNullOrWhiteSpace(address)); + + if (value is null) return null; + return value.EndsWith('/') ? value : value + "/"; } catch (JsonException) { // Not a service index — fall back to the public flat container. + return null; } - return null; } /// From 0229ccf5778a8197e0a1b49cc8f2ae4338defb1d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:10:42 -0700 Subject: [PATCH 16/37] Isolate the scaffolded TestApp from the repo's build rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bootstrap job's "Build TestApp (packaged single-project MSIX)" step failed with 36 IL2081 trim-analysis errors from CsWinRT's generated ABI code and NETSDK1144. Cause: the TestApp is scaffolded under the repo so NuGet's parent-dir walk finds the root nuget.config (which is the point — it maps local-nupkgs/), but MSBuild's walk then also finds the root Directory.Build.props/.targets, which set IsAotCompatible=true and promote IL trim warnings to hard errors. A packaged Release build runs ILLink, and CsWinRT's generated marshalling emits IL2081 by design, so the app cannot build under repo rules. Outside the repo those are warnings and the build succeeds — which is why this only appeared once the templates started scaffolding packaged apps. Shadow both files with ``, the same trick already used for Directory.Packages.props, and for the same stated reason: faithfully mimic an end-user scaffold, which has none of these above it. Verified the mechanism rather than assuming it — a probe project under the same directory reports IsAotCompatible=[true] without the shadow and [] with it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 80de414bb..3209efa68 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -222,14 +222,23 @@ jobs: # Inside the repo so the root nuget.config (which maps local-nupkgs/) # is picked up by NuGet's parent-dir walk. New-Item -ItemType Directory -Path TestProjects | Out-Null - # The scaffolded TestApp lives under the repo, so NuGet's parent-dir - # walk would otherwise inherit the repo-root Directory.Packages.props - # and flip the app into Central Package Management — conflicting with - # the template's inline PackageReference versions (NU1008). Drop a - # nearer Directory.Packages.props that shadows the root one and turns - # CPM off, faithfully mimicking a real end-user scaffold (which has no - # central package props above it). + # The scaffolded TestApp lives under the repo, so MSBuild's and NuGet's + # parent-dir walks would otherwise apply the repo's own build rules to + # what is meant to be an end-user scaffold. Both walks stop at the first + # file they find, so shadowing them here isolates the app — which is + # what a real scaffold outside the repo gets. + # + # Directory.Packages.props — the root one flips the app into Central + # Package Management, conflicting with the template's inline + # PackageReference versions (NU1008). + # Directory.Build.props/.targets — the root pair sets + # IsAotCompatible=true and promotes IL trim warnings to hard errors. + # A packaged Release build runs ILLink, and CsWinRT's generated ABI + # code emits IL2081 by design, so the app fails to build with 36 + # errors and NETSDK1144. Outside the repo those stay warnings. Set-Content -Path TestProjects/Directory.Packages.props -Value 'false' + Set-Content -Path TestProjects/Directory.Build.props -Value '' + Set-Content -Path TestProjects/Directory.Build.targets -Value '' Push-Location TestProjects try { # Bootstrap validates the local 0.0.0-local feed produced by From 44c94f2144286ee3e65581d6edf752c317615e3d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:35:51 -0700 Subject: [PATCH 17/37] Enforce the feed policy across redirects, and stop reporting unusable templates as success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 7. HttpClient follows redirects by default, which defeated IsAllowedFeedUrl entirely: a validated HTTPS service index could 302 to plaintext HTTP, or to a host with credentials in the URL, and the body would be accepted without the policy ever seeing that address. Since the version list is what selects the package to install, that is a real downgrade vector. Redirects are now handled manually with the policy re-applied at every hop and a 5-hop ceiling. Mutation-checked: removing the per-hop check reddens exactly the two downgrade tests and nothing else. A compliant-redirect case is included so the negative tests cannot pass vacuously against a method that always returns null. Two remaining false-PASS paths, the same class already fixed in `mur templates install`: - bootstrap warned when step 5's verification failed but still printed `dotnet new reactor` in its closing guidance — the exact command it had just proved unavailable. `$templatesVerified` is hoisted and the guidance now branches on it. - `mur upgrade` checked only the install outcome. An older pack (0.0.6-alpha predates the Reactor templates) installs cleanly and still cannot scaffold, so it reported "Upgrade complete." It now verifies availability, and fails when the user explicitly pinned a source or version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 14 ++- .../Templates/WinAppSdkTemplates.cs | 67 ++++++++++- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 12 ++ .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 112 +++++++++++++++++- 4 files changed, 198 insertions(+), 7 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index d52f29b56..9c54d92cd 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -680,6 +680,11 @@ if ($packLocalExit -ne 0) { Fail 'mur pack-local failed' } # NuGet.org with a pointer here. For an unpackaged app, scaffold with # `dotnet new reactor` and set `None` # (see docs/guide/packaging.md). +# +# Hoisted so the closing "Next:" guidance can gate on it: printing +# `dotnet new reactor` after verification failed would hand the user the exact +# command bootstrap just proved unavailable. +$templatesVerified = $false if ($SkipTemplates) { Write-Host '' Write-Host ' Skipping `dotnet new` template install (per -SkipTemplates).' -ForegroundColor Yellow @@ -772,7 +777,6 @@ if ($SkipTemplates) { # status` matches the short name as a whole token, which a naive regex does # not — `\breactor\b` also matches `reactor-mvu` and `winui-reactor`, so a # listing without the blank template would read as success. - $templatesVerified = $false $statusExit = 0 Invoke-ReactorWithRestoreEnvironment ` -NuGetConfig $effectiveNuGetConfig ` @@ -951,6 +955,14 @@ if ($SkipTemplates) { Write-Host ' To scaffold an app, install the pack first:' Write-Host ' mur templates install' Write-Host ' dotnet new reactor -n MyApp' +} elseif (-not $templatesVerified) { + # Same false promise, for the harder case: the pack is installed but step 5 + # proved `dotnet new reactor` does not resolve from it (an older pack such as + # 0.0.6-alpha predates the Reactor templates). + Write-Host ' `dotnet new reactor` is not available — see the warning above.' + Write-Host ' Install a version that provides it, then scaffold:' + Write-Host ' mur templates install --version ' + Write-Host ' dotnet new reactor -n MyApp' } else { Write-Host ' dotnet new reactor -n MyApp' Write-Host ' cd MyApp' diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 2c4bb9dc0..afe0ea7d3 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -533,7 +533,7 @@ internal static bool IsAllowedFeedUrl(string? feed) if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) return EnumerateLocalVersions(source!); - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + using var http = CreateFeedHttpClient(); if (!string.IsNullOrWhiteSpace(feed)) { @@ -556,6 +556,64 @@ internal static bool IsAllowedFeedUrl(string? feed) return TryGetVersions(http, FlatContainerIndexUrl); } + /// + /// An that does not follow redirects on its own. + /// + /// + /// Automatic redirects would defeat : a validated + /// HTTPS URL can redirect to plaintext HTTP, or to a host carrying credentials in + /// the URL, and the body would be accepted without the policy ever seeing that + /// address. follows them itself and re-applies + /// the policy at every hop. + /// + static HttpClient CreateFeedHttpClient() => + new(new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(15), + }; + + /// + /// GETs , re-applying to every + /// redirect hop. Returns null when the policy rejects a hop, the chain is too long, + /// or the request fails. + /// + internal static string? GetStringPolicyChecked(HttpClient http, string url) + { + // Enough for the CDN/vanity-host hops real feeds use, few enough to stop a loop. + const int MaxHops = 5; + + var current = url; + for (var hop = 0; hop < MaxHops; hop++) + { + if (!IsAllowedFeedUrl(current)) + { + Console.Error.WriteLine( + $" warning: refusing to follow '{RedactSource(current)}' — a version feed must be an " + + $"HTTPS URL (or loopback HTTP) with no credentials in its user-info, query string or fragment."); + return null; + } + + using var response = http.GetAsync(current).GetAwaiter().GetResult(); + if ((int)response.StatusCode is >= 300 and < 400) + { + var location = response.Headers.Location; + if (location is null) return null; + // A relative Location is resolved against the hop it came from, then + // re-checked at the top of the next iteration. + current = location.IsAbsoluteUri + ? location.AbsoluteUri + : new Uri(new Uri(current), location).AbsoluteUri; + continue; + } + + response.EnsureSuccessStatusCode(); + return response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + } + + Console.Error.WriteLine($" warning: too many redirects following '{RedactSource(url)}'."); + return null; + } + /// /// Resolves a service index to its flat container and lists the pack's versions /// there. Null on any failure, so the caller can fall back. @@ -565,12 +623,13 @@ internal static bool IsAllowedFeedUrl(string? feed) string? indexJson; try { - indexJson = http.GetStringAsync(serviceIndexUrl).GetAwaiter().GetResult(); + indexJson = GetStringPolicyChecked(http, serviceIndexUrl); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException or InvalidOperationException) { return null; } + if (indexJson is null) return null; var baseAddress = ParsePackageBaseAddress(indexJson); if (baseAddress is null) return null; @@ -596,8 +655,8 @@ internal static bool IsAllowedFeedUrl(string? feed) { try { - var json = http.GetStringAsync(flatContainerIndexUrl).GetAwaiter().GetResult(); - return PackLocalCommand.ParseFlatContainerVersions(json); + var json = GetStringPolicyChecked(http, flatContainerIndexUrl); + return json is null ? null : PackLocalCommand.ParseFlatContainerVersions(json); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException or InvalidOperationException) { diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index d9bc928c5..068e4be37 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -80,6 +80,18 @@ public static int Run(string[] args) } Console.Error.WriteLine($" Could not install {WinAppSdkTemplates.PackageId}; the rest of the upgrade completed."); } + else if (WinAppSdkTemplates.AreTemplatesAvailable() == false) + { + // A successful install is not a usable one: an older pack (0.0.6-alpha + // shipped before the Reactor templates existed) installs cleanly and + // still leaves `dotnet new reactor` unresolvable. Reporting "upgrade + // complete" there hands the user a scaffold command that fails. + Console.Error.WriteLine( + $" {WinAppSdkTemplates.PackageId} is installed but does not provide " + + $"`dotnet new {WinAppSdkTemplates.BlankShortName}` — that version predates the Reactor " + + $"templates. Pin a newer one with `mur templates install --version `."); + if (templateSource is not null || templateVersion is not null) return 1; + } // 3. Refresh Claude plugin (best-effort; not every user has Claude Code). if (!skipPlugin) diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index fea2940a2..093c55ddb 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -479,14 +479,122 @@ public void IsAllowedFeedUrl_also_gates_an_advertised_base_address(string advert [Fact] public void Bootstrap_does_not_advertise_dotnet_new_reactor_when_templates_are_skipped() { - // -SkipTemplates deliberately leaves the pack uninstalled, so printing the - // scaffold command unconditionally promises something that may not resolve. + // Two false promises to avoid: -SkipTemplates leaves the pack uninstalled, + // and a *verified-unavailable* pack (0.0.6-alpha predates the Reactor + // templates) is installed but cannot scaffold. Printing the command in + // either case hands the user something that fails immediately. var (path, text) = ReadRepoFile("bootstrap.ps1"); var normalized = text.Replace("\r\n", "\n"); var next = normalized[normalized.LastIndexOf("Write-Host 'Next:'", StringComparison.Ordinal)..]; Assert.True( next.Contains("if ($SkipTemplates)", StringComparison.Ordinal), $"'{path}' must gate the `dotnet new reactor` next-step guidance on -SkipTemplates."); + Assert.True( + next.Contains("elseif (-not $templatesVerified)", StringComparison.Ordinal), + $"'{path}' must also gate that guidance on the step-5 verification result."); + } + + [Fact] + public void Upgrade_verifies_template_availability_after_installing() + { + // `mur upgrade` reporting success on an installed-but-unusable pack is the + // same false PASS bootstrap and `mur templates install` already guard. + var (path, text) = ReadRepoFile(global::System.IO.Path.Join("src", "Reactor.Cli", "Upgrade", "UpgradeCommand.cs")); + Assert.True( + text.Contains("AreTemplatesAvailable() == false", StringComparison.Ordinal), + $"'{path}' must check template availability after Install, not just the install outcome."); + } + + // ── Redirect policy on the version-metadata fetch ───────────────────── + // + // HttpClient follows redirects by default, which would defeat + // IsAllowedFeedUrl entirely: a validated HTTPS service index can 302 to + // plaintext HTTP and the body would be accepted without the policy ever + // seeing that address. The version list is what selects the package to + // install, so that is a real downgrade vector. + + sealed class StubHandler : global::System.Net.Http.HttpMessageHandler + { + readonly global::System.Collections.Generic.Queue _responses; + + public global::System.Collections.Generic.List Requested { get; } = new(); + + public StubHandler(params global::System.Net.Http.HttpResponseMessage[] responses) => + _responses = new global::System.Collections.Generic.Queue(responses); + + protected override global::System.Threading.Tasks.Task SendAsync( + global::System.Net.Http.HttpRequestMessage request, + global::System.Threading.CancellationToken cancellationToken) + { + Requested.Add(request.RequestUri!.AbsoluteUri); + return global::System.Threading.Tasks.Task.FromResult( + _responses.Count > 0 + ? _responses.Dequeue() + : new global::System.Net.Http.HttpResponseMessage(global::System.Net.HttpStatusCode.NotFound)); + } + } + + static global::System.Net.Http.HttpResponseMessage Redirect(string location) + { + var response = new global::System.Net.Http.HttpResponseMessage(global::System.Net.HttpStatusCode.Found); + response.Headers.Location = new Uri(location); + return response; + } + + [Fact] + public void GetStringPolicyChecked_refuses_a_redirect_that_downgrades_to_plaintext() + { + var handler = new StubHandler(Redirect("http://evil.example.com/flat2/index.json")); + using var http = new global::System.Net.Http.HttpClient(handler); + + var body = WinAppSdkTemplates.GetStringPolicyChecked(http, "https://feed.example.com/v3/index.json"); + + Assert.Null(body); + // The load-bearing half: the plaintext hop must never be requested at all. + Assert.Equal(new[] { "https://feed.example.com/v3/index.json" }, handler.Requested); + } + + [Fact] + public void GetStringPolicyChecked_refuses_a_redirect_that_carries_credentials() + { + var handler = new StubHandler(Redirect("https://user:pat@feed.example.com/flat2/index.json")); + using var http = new global::System.Net.Http.HttpClient(handler); + + Assert.Null(WinAppSdkTemplates.GetStringPolicyChecked(http, "https://feed.example.com/v3/index.json")); + Assert.Single(handler.Requested); + } + + [Fact] + public void GetStringPolicyChecked_follows_a_compliant_redirect() + { + // The negative cases above prove nothing unless redirects otherwise work: + // a method that always returned null would pass them. + var ok = new global::System.Net.Http.HttpResponseMessage(global::System.Net.HttpStatusCode.OK) + { + Content = new global::System.Net.Http.StringContent("{\"versions\":[\"1.0.0\"]}"), + }; + var handler = new StubHandler(Redirect("https://cdn.example.com/flat2/index.json"), ok); + using var http = new global::System.Net.Http.HttpClient(handler); + + var body = WinAppSdkTemplates.GetStringPolicyChecked(http, "https://feed.example.com/v3/index.json"); + + Assert.Equal("{\"versions\":[\"1.0.0\"]}", body); + Assert.Equal( + new[] { "https://feed.example.com/v3/index.json", "https://cdn.example.com/flat2/index.json" }, + handler.Requested); + } + + [Fact] + public void GetStringPolicyChecked_stops_a_redirect_loop() + { + var handler = new StubHandler( + Redirect("https://a.example.com/1"), Redirect("https://a.example.com/2"), + Redirect("https://a.example.com/3"), Redirect("https://a.example.com/4"), + Redirect("https://a.example.com/5"), Redirect("https://a.example.com/6")); + using var http = new global::System.Net.Http.HttpClient(handler); + + Assert.Null(WinAppSdkTemplates.GetStringPolicyChecked(http, "https://a.example.com/0")); + Assert.Equal(5, handler.Requested.Count); } // ── False-PASS guard: "pack installed" != "templates usable" ─────────── From cec8d7d0c2afc1d142f71e2b678a1139a0cc7093 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:53:12 -0700 Subject: [PATCH 18/37] Mask file-URI query/fragment, cover the install parser, and honour --templates-feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 8. RedactSource short-circuited on `uri.IsFile` as "a local path, nothing to hide". A file URI can still carry a query or fragment, and either can hold a token, so it now only short-circuits when there is demonstrably nothing to mask. (User-info on the file scheme turns out not to be constructible at all — Uri rejects it — so the test asserts the case that is actually reachable rather than one that silently exercises the TryCreate-failure path.) `mur upgrade`'s explicit-request checks listed --templates-source and --templates-version but not the new --templates-feed, so a caller who named a feed and got a failed or unusable install still saw exit 0. `mur templates install`'s strict argv parser had no automated coverage, which is the parser whose whole job is to stop `--sorce ./pkgs` from silently installing from the configured feeds instead. Added TemplatesCommandArgvTests covering help, unknown options, bare positionals, missing values, a following flag misread as a value, and both no-subcommand and unknown-subcommand paths. Only branches that return before touching the machine are exercised — a real install is a global `dotnet new install` — and each case also asserts the install banner never printed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 12 ++- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 4 +- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 94 +++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index afe0ea7d3..db1c3e0b4 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -317,8 +317,16 @@ internal static InstallAction PlanInstall(string? installed, string? target, boo internal static string RedactSource(string source) { if (string.IsNullOrWhiteSpace(source)) return source; - if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || uri.IsFile) - return source; // local folder path — nothing secret in it + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri)) return source; // plain local path + + // A local *path* has nothing secret in it, but `file://user:pat@host/share` + // is also IsFile — returning it unchanged would print the credential. Only + // short-circuit when there is demonstrably nothing to mask. + if (uri.IsFile && + string.IsNullOrEmpty(uri.UserInfo) && + string.IsNullOrEmpty(uri.Query) && + string.IsNullOrEmpty(uri.Fragment)) + return source; var builder = new UriBuilder(uri) { diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index 068e4be37..78f565040 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -73,7 +73,7 @@ public static int Run(string[] args) // Best-effort when it's the routine refresh — a NuGet hiccup shouldn't fail // the whole upgrade. But if the user explicitly asked for a specific source // or version, silently returning 0 would report success for work not done. - if (templateSource is not null || templateVersion is not null) + if (templateSource is not null || templateVersion is not null || templateFeed is not null) { Console.Error.WriteLine($"mur upgrade: could not install {WinAppSdkTemplates.PackageId} as requested."); return 1; @@ -90,7 +90,7 @@ public static int Run(string[] args) $" {WinAppSdkTemplates.PackageId} is installed but does not provide " + $"`dotnet new {WinAppSdkTemplates.BlankShortName}` — that version predates the Reactor " + $"templates. Pin a newer one with `mur templates install --version `."); - if (templateSource is not null || templateVersion is not null) return 1; + if (templateSource is not null || templateVersion is not null || templateFeed is not null) return 1; } // 3. Refresh Claude plugin (best-effort; not every user has Claude Code). diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 093c55ddb..cb7bd65a4 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -359,6 +359,20 @@ Reactor Blank App (Experimental) reactor,reactor-blank [C#] Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank)); } + [Fact] + public void RedactSource_strips_a_query_from_a_file_uri() + { + // A file URI is IsFile, so a blanket "local path, nothing to hide" early + // return skipped masking entirely. User-info is not constructible on the + // file scheme (Uri rejects it), but a query or fragment is — and either can + // carry a token. + var redacted = WinAppSdkTemplates.RedactSource("file://host/share/pkgs?token=SECRET"); + Assert.DoesNotContain("SECRET", redacted, StringComparison.Ordinal); + + // A plain local path still passes through untouched. + Assert.Equal(@"C:\repo\local-nupkgs", WinAppSdkTemplates.RedactSource(@"C:\repo\local-nupkgs")); + } + [Fact] public void RedactSource_strips_a_credential_bearing_fragment() { @@ -777,3 +791,83 @@ static string FindRoot() return dir!; } } + +// ── `mur templates install` argv parsing ────────────────────────────────── +// +// Only the branches that return *before* touching the machine are exercised: +// help, unknown option, missing value, and a bare positional. A real install is +// a global `dotnet new install`, which these tests must never trigger. +// +// Strict parsing is load-bearing: `--sorce ./pkgs` silently ignored would +// install from the configured feeds instead of the folder the user named, and +// the install would look successful. +[Collection("ConsoleTests")] +public sealed class TemplatesCommandArgvTests +{ + static (int ExitCode, string Stdout, string Stderr) Run(params string[] args) + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var stdout = new global::System.IO.StringWriter(); + using var stderr = new global::System.IO.StringWriter(); + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exitCode = TemplatesCommand.Run(args); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + [Fact] + public void Install_help_succeeds_without_installing_anything() + { + // `mur templates install --help` used to fall straight through to a real + // install, so this asserts the help text *and* the absence of the install + // banner rather than just the exit code. + var (exitCode, stdout, _) = Run("install", "--help"); + + Assert.Equal(0, exitCode); + Assert.Contains("Usage: mur templates install", stdout, StringComparison.Ordinal); + Assert.DoesNotContain("Installing " + WinAppSdkTemplates.PackageId, stdout, StringComparison.Ordinal); + } + + [Theory] + // A typo must fail loudly, not install from somewhere else. + [InlineData(new[] { "install", "--sorce", "./pkgs" }, "unknown option")] + [InlineData(new[] { "install", "-x" }, "unknown option")] + // A bare positional is never meaningful here. + [InlineData(new[] { "install", "0.0.7-alpha" }, "unexpected argument")] + // A flag with no value would otherwise silently install the resolved latest. + [InlineData(new[] { "install", "--source" }, "requires a value")] + [InlineData(new[] { "install", "--version" }, "requires a value")] + [InlineData(new[] { "install", "--feed" }, "requires a value")] + // A following flag is not a value. + [InlineData(new[] { "install", "--source", "--version", "1.0.0" }, "requires a value")] + public void Install_rejects_bad_argv(string[] args, string expected) + { + var (exitCode, stdout, stderr) = Run(args); + + Assert.Equal(1, exitCode); + Assert.Contains(expected, stderr, StringComparison.Ordinal); + // Nothing may have been installed on the way to the error. + Assert.DoesNotContain("Installing " + WinAppSdkTemplates.PackageId, stdout, StringComparison.Ordinal); + } + + [Fact] + public void Unknown_subcommand_and_no_subcommand_both_show_help() + { + var (missing, missingOut, _) = Run(); + Assert.Equal(1, missing); + Assert.Contains("mur templates", missingOut, StringComparison.Ordinal); + + var (unknown, _, unknownErr) = Run("instal"); + Assert.Equal(1, unknown); + Assert.Contains("instal", unknownErr, StringComparison.Ordinal); + } +} \ No newline at end of file From e4bae82e4f939f4fc0ddd050ef746cb9be8f7e58 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:56:09 -0700 Subject: [PATCH 19/37] Dispose the stub handler's responses Code-quality review: StubHandler allocated a fresh NotFound response on every exhausted-queue request and never disposed it. One reusable instance, plus a Dispose override that also drains any responses the test did not consume. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- tests/Reactor.Tests/WinAppSdkTemplatesTests.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index cb7bd65a4..3a1b03af8 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -530,6 +530,8 @@ public void Upgrade_verifies_template_availability_after_installing() sealed class StubHandler : global::System.Net.Http.HttpMessageHandler { readonly global::System.Collections.Generic.Queue _responses; + readonly global::System.Net.Http.HttpResponseMessage _exhausted = + new(global::System.Net.HttpStatusCode.NotFound); public global::System.Collections.Generic.List Requested { get; } = new(); @@ -542,9 +544,17 @@ public StubHandler(params global::System.Net.Http.HttpResponseMessage[] response { Requested.Add(request.RequestUri!.AbsoluteUri); return global::System.Threading.Tasks.Task.FromResult( - _responses.Count > 0 - ? _responses.Dequeue() - : new global::System.Net.Http.HttpResponseMessage(global::System.Net.HttpStatusCode.NotFound)); + _responses.Count > 0 ? _responses.Dequeue() : _exhausted); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _exhausted.Dispose(); + while (_responses.Count > 0) _responses.Dequeue().Dispose(); + } + base.Dispose(disposing); } } From e7e9de9130766a9f26a934af0903b76a8fe8b751 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:08:15 -0700 Subject: [PATCH 20/37] Resolve a relative --templates-source against the caller's CWD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 9. `mur upgrade --templates-source ./pkgs` passed the path through unchanged while Install() runs `dotnet new install --add-source` with repoRoot as the child process working directory. The existence check therefore resolved against the caller's CWD and the install against repoRoot — so a valid source could fail, or silently name a different folder. `mur templates install` and bootstrap.ps1 already normalize for exactly this reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 7 +++++++ tests/Reactor.Tests/WinAppSdkTemplatesTests.cs | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index 78f565040..9f9678e20 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -64,6 +64,13 @@ public static int Run(string[] args) return 1; } Console.WriteLine($"==> Checking `dotnet new {WinAppSdkTemplates.BlankShortName}` templates ({WinAppSdkTemplates.PackageId})"); + // Resolve a relative folder against the caller's CWD before handing it on. + // Install() runs `dotnet new install --add-source` with repoRoot as the + // working directory, so an unqualified path would be resolved there + // instead — pointing at a different folder, or none. TemplatesCommand and + // bootstrap.ps1 already normalize for the same reason. + if (!string.IsNullOrWhiteSpace(templateSource) && Directory.Exists(templateSource)) + templateSource = Path.GetFullPath(templateSource!); // Install() is a no-op when the resolved version is already installed, and // deliberately leaves an existing install alone when it can't resolve a // newer one — so this is safe to run on every upgrade. diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 3a1b03af8..96a92e2f1 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -517,6 +517,15 @@ public void Upgrade_verifies_template_availability_after_installing() Assert.True( text.Contains("AreTemplatesAvailable() == false", StringComparison.Ordinal), $"'{path}' must check template availability after Install, not just the install outcome."); + + // Install() runs `dotnet new install --add-source` with repoRoot as the + // working directory, so a relative --templates-source resolved there would + // name a different folder than the one the caller typed. + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch( + text.Replace("\r\n", "\n"), + @"templateSource\s*=\s*Path\.GetFullPath\(templateSource!\)"), + $"'{path}' must resolve a relative --templates-source against the caller's CWD."); } // ── Redirect policy on the version-metadata fetch ───────────────────── From bb7163d5013bd13cc5e2d5a28a6706655c270878 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:21:36 -0700 Subject: [PATCH 21/37] Drain both pipes concurrently in RunCapture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 10. RunCapture read stdout to the end and only then read stderr. `dotnet new` can fill the unread stderr pipe and block before it exits, so the CLI hangs — a deadlock, not a slow path. Every `mur templates status`, `mur doctor` template probe and bootstrap verification goes through this method. Both streams are now started with ReadToEndAsync and awaited together, matching the pattern CheckCommand already documents for the same hazard. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- src/Reactor.Cli/Templates/WinAppSdkTemplates.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index db1c3e0b4..bb3cef9d2 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -760,12 +760,16 @@ static int Run(string workingDirectory, params string[] arguments) { using var proc = Process.Start(psi); if (proc is null) return null; - var stdout = proc.StandardOutput.ReadToEnd(); - var stderr = proc.StandardError.ReadToEnd(); + // Drain both pipes concurrently. Reading stdout to the end first lets + // `dotnet new` fill the unread stderr pipe and block before it exits — + // a deadlock, not a slow path. CheckCommand documents the same hazard. + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + global::System.Threading.Tasks.Task.WaitAll(stdoutTask, stderrTask); proc.WaitForExit(); // `dotnet new uninstall` exits non-zero when nothing is installed // while still printing a usable listing, so don't gate on ExitCode. - return stdout + stderr; + return stdoutTask.Result + stderrTask.Result; } catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) { From a3f3ec0539d79d4a195f240b90bf3efd5f922b7e Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:36:52 -0700 Subject: [PATCH 22/37] Mask credentials in URL-like sources that Uri refuses to parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 11. RedactSource returned its input unchanged when Uri.TryCreate failed, on the assumption that anything unparsable is a local path. It isn't: Uri rejects user-info on the file scheme outright, so `file://user:pat@host/share` lands in exactly that branch — and that branch runs on every *rejected* --source, which is precisely the set of values a user is most likely to have typed a PAT into. URL-looking values (containing "://") are now masked best-effort: anything from the first '?' or '#' is dropped, and the authority's user-info is replaced. Plain paths — Windows, UNC and relative — still pass through untouched, since the error messages that quote them are useless if mangled. Mutation-checked, which also corrected the test's own comment: removing the new masking reddens the file-URI row and only that row, so the bracketed-URL rows go through the ordinary UriBuilder path rather than this one. The comment now says that instead of implying all four exercise the new code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 42 ++++++++++++++++++- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 28 +++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index bb3cef9d2..1cbcc8c7a 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -317,7 +317,17 @@ internal static InstallAction PlanInstall(string? installed, string? target, boo internal static string RedactSource(string source) { if (string.IsNullOrWhiteSpace(source)) return source; - if (!Uri.TryCreate(source, UriKind.Absolute, out var uri)) return source; // plain local path + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri)) + { + // Unparsable. A plain local path has nothing to hide, but a malformed + // URL-ish value can still carry a PAT — and `Uri` is stricter than it + // looks: `file://user:pat@host/share` throws outright, so the + // credential-bearing cases land *here* rather than in the branches + // below. Mask conservatively when it looks like a URL. + return source.Contains("://", StringComparison.Ordinal) + ? RedactUnparsableUrl(source) + : source; + } // A local *path* has nothing secret in it, but `file://user:pat@host/share` // is also IsFile — returning it unchanged would print the credential. Only @@ -338,6 +348,36 @@ internal static string RedactSource(string source) return builder.Uri.ToString(); } + /// + /// Best-effort masking for a URL-looking value that refused to + /// parse: drops anything from the first ? or #, and replaces the + /// authority's user-info with ***. + /// + /// + /// Deliberately string-based. The input is by definition not a valid URI, so + /// there is no parser to lean on — and the alternative (echoing it verbatim) + /// is what leaks the token. + /// + static string RedactUnparsableUrl(string source) + { + var cut = source.IndexOfAny(new[] { '?', '#' }); + var head = cut >= 0 ? source[..cut] + "***" : source; + + var schemeEnd = head.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd < 0) return head; + + var authorityStart = schemeEnd + 3; + var authorityEnd = head.IndexOf('/', authorityStart); + var authority = authorityEnd < 0 ? head[authorityStart..] : head[authorityStart..authorityEnd]; + + // Last '@' wins: a password may itself contain one. + var at = authority.LastIndexOf('@'); + if (at < 0) return head; + + var tail = authorityEnd < 0 ? string.Empty : head[authorityEnd..]; + return head[..authorityStart] + "***" + authority[at..] + tail; + } + /// /// Installs (or updates) the template pack. /// diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 96a92e2f1..7baa966b5 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -359,6 +359,34 @@ Reactor Blank App (Experimental) reactor,reactor-blank [C#] Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank)); } + [Theory] + // The property under test is simply "the secret never reaches the console". + // Which branch enforces it varies, and not obviously: `Uri` rejects user-info + // on the file scheme outright, so that row is the one that lands in + // RedactSource's TryCreate-failure path — the path that used to echo its input + // verbatim, and the path that runs on every *rejected* --source, i.e. exactly + // the values a user is most likely to have typed a PAT into. (Mutation-checked: + // removing the unparsable-URL masking reddens that row and only that row.) + [InlineData("file://user:pat@host/share/pkgs", "pat")] + [InlineData("https://user:SECRET@pkgs.example.com/v3/index.json[", "SECRET")] + [InlineData("https://pkgs.example.com/v3/index.json[?api-key=SECRET", "SECRET")] + [InlineData("https://pkgs.example.com/v3/index.json[#SECRET", "SECRET")] + public void RedactSource_masks_url_like_values_carrying_secrets(string source, string secret) + { + Assert.DoesNotContain(secret, WinAppSdkTemplates.RedactSource(source), StringComparison.Ordinal); + } + + [Theory] + // Local paths have nothing to mask, and mangling them would make the error + // messages that quote them useless. + [InlineData(@"C:\repo\local-nupkgs")] + [InlineData(@"\\server\share\pkgs")] + [InlineData("./pkgs")] + public void RedactSource_leaves_path_like_values_alone(string source) + { + Assert.Equal(source, WinAppSdkTemplates.RedactSource(source)); + } + [Fact] public void RedactSource_strips_a_query_from_a_file_uri() { From fbc10d31ab2c82a5ff04522834d0b4895c9dcbc6 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:50:37 -0700 Subject: [PATCH 23/37] State the ShortNames contract, and ground the parser fixtures in the real listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 12. ShortNames was documented as "every Reactor short name the pack registers". It isn't: the published 0.0.7-alpha pack registers nine names for four templates — each has a `winui-` alias, and the blank one also answers to `reactor-blank`. Verified against `dotnet new list reactor` rather than taken on faith. Kept the four canonical names (listing nine under "scaffold an app with" would be noise) and made the contract explicit instead, pointing callers that need to recognise an arbitrary alias at the listing. The same check turned up that my InterpretTemplateListOutput fixtures were invented rather than measured, and were missing a trap the real output contains: the Tags column carries a slash-delimited "Reactor". Both fixtures are now verbatim from the shipped pack, so the parser is tested against what it will actually see. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 12 ++++++- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 36 +++++++++++-------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 1cbcc8c7a..39e73ab52 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -46,7 +46,17 @@ public static class WinAppSdkTemplates /// `dotnet new` short name of the blank Reactor template. public const string BlankShortName = "reactor"; - /// Every Reactor short name the pack registers. + /// + /// The canonical `dotnet new` short name of each Reactor template — one per + /// template, for scaffold guidance. + /// + /// + /// Deliberately not every registered short name. Each template also carries a + /// winui--prefixed alias (and the blank one a reactor-blank + /// alias), so the pack registers nine names for four templates. Listing all + /// nine as "scaffold an app with" would be noise; callers that need to + /// recognise an arbitrary alias should match the listing, not this array. + /// public static readonly string[] ShortNames = [ "reactor", diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 7baa966b5..e9d2e586b 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -37,10 +37,12 @@ public void PackageId_is_the_windows_app_sdk_template_pack() } [Fact] - public void ShortNames_cover_every_reactor_template_in_the_pack() + public void ShortNames_are_the_canonical_name_of_each_template_in_the_pack() { - // The four short names the pack registers (microsoft/WindowsAppSDK#6620). - // `reactor` is the blank one users are pointed at first. + // One canonical name per template (microsoft/WindowsAppSDK#6620), NOT every + // registered short name: the shipped pack also registers a `winui-` alias + // for each, plus `reactor-blank`, so nine names cover four templates. + // Verified against the published 0.0.7-alpha listing. Assert.Equal( new[] { "reactor", "reactor-mvu", "reactor-navview", "reactor-tabview" }, WinAppSdkTemplates.ShortNames); @@ -325,19 +327,20 @@ public void InterpretInstalledVersionOutput_returns_null_when_no_version_line_fo [Fact] public void InterpretTemplateListOutput_requires_the_exact_short_name() { - // Two traps in one fixture: + // Three traps in one fixture, all from the real listing shape: // • '-' is a word boundary, so `\breactor\b` / Contains("reactor") also - // matches `reactor-mvu` and `winui-reactor`; + // matches `reactor-mvu` and the real `winui-reactor-mvu` alias; // • the Template Name column carries the capitalised prose word // "Reactor" as a standalone token, so a case-insensitive token match - // passes too. - // Neither means the blank `reactor` template is installed. + // passes too; + // • so does the Tags column, where "Reactor" is slash-delimited. + // None of them means the blank `reactor` template is installed. const string withoutBlank = """ These templates matched your input: 'reactor' - Template Name Short Name Language - --------------------------------- ----------------------------- -------- - Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] + Template Name Short Name Language Tags + ----------------------------------------- ------------------------------------- -------- ------------------------------------------ + Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] Windows/WinUI/Desktop/Reactor/Experimental """; Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput(withoutBlank)); @@ -346,14 +349,17 @@ Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] [Fact] public void InterpretTemplateListOutput_accepts_the_short_name_in_a_comma_list() { - // Real listings put the blank template's aliases in one comma-separated - // column, so the token match must survive commas on both sides. + // Verbatim from the published 0.0.7-alpha pack, so the fixture is measured + // rather than imagined. Three things have to survive: the comma-separated + // alias column, the capitalised prose "Reactor" in the Template Name + // column, and "Reactor" again inside the slash-delimited Tags column. const string withBlank = """ These templates matched your input: 'reactor' - Template Name Short Name Language - --------------------------------- ----------------------------- -------- - Reactor Blank App (Experimental) reactor,reactor-blank [C#] + Template Name Short Name Language Tags + ----------------------------------------- ------------------------------------- -------- ------------------------------------------ + Reactor Blank App (Experimental) reactor,reactor-blank,winui-reactor [C#] Windows/WinUI/Desktop/Reactor/Experimental + Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] Windows/WinUI/Desktop/Reactor/Experimental """; Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank)); From c986e1c21dace06fe6ba33fdf5b96d78b4f35c74 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:03:11 -0700 Subject: [PATCH 24/37] Distinguish "installed but unusable" from "install failed" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 13 — an interaction between two earlier fixes in this PR. Round 5 made `mur templates install` exit non-zero when the pack installs but `dotnet new reactor` still doesn't resolve. bootstrap treats any non-zero from that command as fatal, so an old 0.0.6-alpha install hit Fail and exited before reaching the verification step and the guidance gated on it — the two things rounds 5 and 7 added for exactly that case. Both were unreachable. The command now exits 2 for "installed, not usable", distinct from 1 for a real install failure (or rejected arguments), and bootstrap lets 2 through. The mapping is a pure ExitCodeForAvailability so the contract bootstrap depends on is testable without touching the machine, and the help text documents all three codes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 6 +++- src/Reactor.Cli/Templates/TemplatesCommand.cs | 28 +++++++++++++++++-- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 9c54d92cd..6036425ad 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -757,7 +757,11 @@ if ($SkipTemplates) { -- @murTemplateArgs } } - if ($templatesExit -ne 0) { + # Exit 2 is "the pack is installed but `dotnet new reactor` does not resolve" + # (see TemplatesCommand.TemplatesUnavailableExit). That is not an install + # failure: the verification below reports it, and the closing guidance adapts. + # Collapsing it into this Fail would make both of those unreachable. + if ($templatesExit -ne 0 -and $templatesExit -ne 2) { Fail (@( "Installing $wasdkTemplatePackageId failed.", " The Reactor templates ship in the Windows App SDK template pack.", diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index 425735ac5..10c780f99 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -107,7 +107,7 @@ static int Install(string[] args) Console.WriteLine("Scaffold an app with:"); foreach (var name in WinAppSdkTemplates.ShortNames) Console.WriteLine($" dotnet new {name} -n MyApp"); - return 0; + return ExitCodeForAvailability(available); } Console.Error.WriteLine(); @@ -117,9 +117,28 @@ static int Install(string[] args) : $"mur templates install: {WinAppSdkTemplates.PackageId} is installed but does not provide " + $"`dotnet new {WinAppSdkTemplates.BlankShortName}` — that version predates the Reactor " + $"templates. Pin a newer one with `mur templates install --version `."); - return 1; + return TemplatesUnavailableExit; } + /// + /// Exit code for "the install itself worked, but `dotnet new reactor` still + /// doesn't resolve" — distinct from 1, which means the install failed. + /// + /// + /// bootstrap.ps1 needs to tell these apart. A genuine install failure is fatal + /// there, but an old-but-installed pack has its own warning path and next-step + /// guidance; collapsing both onto 1 makes that path unreachable. + /// + internal const int TemplatesUnavailableExit = 2; + + /// + /// Exit code for an install that ran, given the post-install availability probe + /// (null = could not enumerate). Pure, so the contract bootstrap.ps1 + /// depends on is testable without touching the machine. + /// + internal static int ExitCodeForAvailability(bool? available) => + available == true ? 0 : TemplatesUnavailableExit; + static int Status() { // Report the question that matters — "can I scaffold?" — not merely @@ -199,6 +218,11 @@ static void ShowInstallHelp() Console.WriteLine(" machines that reach a mirror but not nuget.org. Used only"); Console.WriteLine(" for version lookup; falls back to nuget.org."); Console.WriteLine(" --help, -h Show this help."); + Console.WriteLine(); + Console.WriteLine("Exit codes:"); + Console.WriteLine(" 0 installed (or already current) and `dotnet new reactor` resolves"); + Console.WriteLine(" 1 the install failed, or the arguments were rejected"); + Console.WriteLine(" 2 the pack is installed but `dotnet new reactor` does not resolve"); } /// diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index e9d2e586b..0f74b7284 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -664,6 +664,34 @@ public void GetStringPolicyChecked_stops_a_redirect_loop() Assert.Equal(5, handler.Requested.Count); } + [Theory] + // The contract bootstrap.ps1 depends on: 0 = usable, 2 = installed but the + // short name does not resolve, which bootstrap must NOT treat as a fatal + // install failure (it has its own warning path and adapted next-step + // guidance). "Could not enumerate" is also 2 — unverified is not usable. + [InlineData(true, 0)] + [InlineData(false, 2)] + [InlineData(null, 2)] + public void Install_exit_code_distinguishes_unusable_from_failed(bool? available, int expected) + { + Assert.Equal(expected, TemplatesCommand.ExitCodeForAvailability(available)); + Assert.NotEqual(1, TemplatesCommand.ExitCodeForAvailability(available)); + } + + [Fact] + public void Bootstrap_does_not_treat_an_unusable_pack_as_an_install_failure() + { + // Regression: `mur templates install` returning non-zero for an installed + // but unusable pack made bootstrap Fail before it ever reached the + // verification and the gated guidance below it. + var (path, text) = ReadRepoFile("bootstrap.ps1"); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch( + text.Replace("\r\n", "\n"), + @"\$templatesExit -ne 0 -and \$templatesExit -ne " + TemplatesCommand.TemplatesUnavailableExit), + $"'{path}' must let exit {TemplatesCommand.TemplatesUnavailableExit} through to the verification step."); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had From 1038285ea80cec36f061ab02747f743e991671a2 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:17:17 -0700 Subject: [PATCH 25/37] Separate the three `mur templates status` failure modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 14. `mur templates status` returned 1 for three different situations — the template engine could not be enumerated, the pack is installed without the Reactor templates, and the pack is not installed — so bootstrap warned "is installed but does not provide `dotnet new reactor`; that version predates the Reactor templates" for all of them. For a machine with no pack at all, or a probe that simply failed, that sends the user to re-pin a version that was never the problem. status now exits 0 / 1 (probe failed) / 2 (installed but unusable) / 3 (not installed), and bootstrap emits the matching remediation for each. The mapping is a pure StatusExitCode(bool?, bool?) so the distinction is covered by real assertions, including the case where the *package list* is unreadable while the short name is absent — that is a probe failure, not a stale pack. Smoke-tested live: on a machine with 0.0.7-alpha installed, `mur templates status` prints the available line and exits 0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 25 +++++- src/Reactor.Cli/Templates/TemplatesCommand.cs | 81 ++++++++++++++----- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 34 ++++++++ 3 files changed, 117 insertions(+), 23 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 6036425ad..ea7567e91 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -801,10 +801,29 @@ if ($SkipTemplates) { if ($templatesVerified) { Write-Ok '`dotnet new reactor` templates registered' } else { + # `mur templates status` reports three distinct not-usable situations + # (see TemplatesCommand.StatusExit). They need different advice: telling + # someone their pack "predates the Reactor templates" when it is not + # installed at all — or when the probe itself failed — sends them to + # re-pin a version that was never the problem. Write-Host '' - Write-Host " [warn] $wasdkTemplatePackageId is installed but does not provide ``dotnet new reactor``." -ForegroundColor Yellow - Write-Host " That version predates the Reactor templates. Re-run with network access, or pin a newer one:" - Write-Host " mur templates install --version " + switch ($statusExit) { + 2 { + Write-Host " [warn] $wasdkTemplatePackageId is installed but does not provide ``dotnet new reactor``." -ForegroundColor Yellow + Write-Host " That version predates the Reactor templates. Re-run with network access, or pin a newer one:" + Write-Host " mur templates install --version " + } + 3 { + Write-Host " [warn] $wasdkTemplatePackageId is not installed, so ``dotnet new reactor`` is unavailable." -ForegroundColor Yellow + Write-Host " Re-run with network access, or install from a local folder of nupkgs:" + Write-Host " mur templates install --source " + } + default { + Write-Host " [warn] Could not enumerate ``dotnet new`` templates, so ``dotnet new reactor`` is unverified." -ForegroundColor Yellow + Write-Host " This is a probe failure, not a known-bad install. Check with:" + Write-Host " mur templates status" + } + } } } diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index 10c780f99..92cd193ae 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -139,36 +139,77 @@ static int Install(string[] args) internal static int ExitCodeForAvailability(bool? available) => available == true ? 0 : TemplatesUnavailableExit; + /// + /// `mur templates status` exit codes. Three distinct situations that all mean + /// "cannot scaffold" but call for different remediation, so callers (bootstrap + /// especially) must not collapse them into one message. + /// + internal static class StatusExit + { + /// `dotnet new reactor` resolves. + public const int Available = 0; + /// The template engine could not be enumerated at all. + public const int ProbeFailed = 1; + /// The pack is installed but does not carry the Reactor templates. + public const int InstalledButUnusable = 2; + /// The pack is not installed. + public const int NotInstalled = 3; + } + + /// + /// Maps the two probes to a code. Pure, so the + /// distinction bootstrap.ps1 branches on is testable without a machine. + /// + /// null when the template engine could not be enumerated. + /// null when the installed-package list could not be read. + internal static int StatusExitCode(bool? available, bool? packageInstalled) + { + if (available is null) return StatusExit.ProbeFailed; + if (available.Value) return StatusExit.Available; + // Absent short name: is the pack there at all? "Installed but too old" and + // "never installed" need different advice, and an unreadable package list + // is a probe failure rather than either. + return packageInstalled switch + { + true => StatusExit.InstalledButUnusable, + false => StatusExit.NotInstalled, + null => StatusExit.ProbeFailed, + }; + } + static int Status() { // Report the question that matters — "can I scaffold?" — not merely // whether the package id appears in the installed list. var available = WinAppSdkTemplates.AreTemplatesAvailable(); - if (available is null) - { - Console.Error.WriteLine("mur templates status: could not enumerate `dotnet new` templates."); - return 1; - } + var packageInstalled = available == false ? WinAppSdkTemplates.IsPackageInstalled() : null; + var exitCode = StatusExitCode(available, packageInstalled); + var version = exitCode == StatusExit.ProbeFailed ? null : WinAppSdkTemplates.GetInstalledVersion(); - var version = WinAppSdkTemplates.GetInstalledVersion(); - if (available.Value) + switch (exitCode) { - Console.WriteLine(version is null - ? $"`dotnet new {WinAppSdkTemplates.BlankShortName}` is available." - : $"`dotnet new {WinAppSdkTemplates.BlankShortName}` is available ({WinAppSdkTemplates.PackageId} {version})."); - return 0; - } + case StatusExit.Available: + Console.WriteLine(version is null + ? $"`dotnet new {WinAppSdkTemplates.BlankShortName}` is available." + : $"`dotnet new {WinAppSdkTemplates.BlankShortName}` is available ({WinAppSdkTemplates.PackageId} {version})."); + break; - if (WinAppSdkTemplates.IsPackageInstalled() == true) - { - Console.WriteLine( - $"{WinAppSdkTemplates.PackageId} {version ?? "(unknown)"} is installed, but it does not provide " + - $"`dotnet new {WinAppSdkTemplates.BlankShortName}`. Update it with `mur templates install`."); - return 1; + case StatusExit.InstalledButUnusable: + Console.WriteLine( + $"{WinAppSdkTemplates.PackageId} {version ?? "(unknown)"} is installed, but it does not provide " + + $"`dotnet new {WinAppSdkTemplates.BlankShortName}`. Update it with `mur templates install`."); + break; + + case StatusExit.NotInstalled: + Console.WriteLine($"{WinAppSdkTemplates.PackageId} is NOT installed. Run `mur templates install`."); + break; + + default: + Console.Error.WriteLine("mur templates status: could not enumerate `dotnet new` templates."); + break; } - Console.WriteLine($"{WinAppSdkTemplates.PackageId} is NOT installed. Run `mur templates install`."); - return 1; + return exitCode; } /// diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 0f74b7284..5f0b01e63 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -692,6 +692,40 @@ public void Bootstrap_does_not_treat_an_unusable_pack_as_an_install_failure() $"'{path}' must let exit {TemplatesCommand.TemplatesUnavailableExit} through to the verification step."); } + [Theory] + // Three distinct "cannot scaffold" situations that need different advice: + // telling someone their pack "predates the Reactor templates" when it is not + // installed at all — or when the probe itself failed — sends them to re-pin a + // version that was never the problem. + [InlineData(true, null, 0)] // resolves + [InlineData(null, null, 1)] // template engine unreadable + [InlineData(false, true, 2)] // pack present, short name absent → too old + [InlineData(false, false, 3)] // pack absent + [InlineData(false, null, 1)] // package list unreadable → probe failure, not "too old" + public void StatusExitCode_separates_unusable_missing_and_probe_failure( + bool? available, bool? packageInstalled, int expected) + { + Assert.Equal(expected, TemplatesCommand.StatusExitCode(available, packageInstalled)); + } + + [Fact] + public void Bootstrap_gives_different_advice_per_status_outcome() + { + // Regression: a single warning claiming "installed but too old" fired for + // all three, including "not installed" and "probe failed". + var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); + var start = normalized.IndexOf("$templatesVerified = ($statusExit -eq 0)", StringComparison.Ordinal); + Assert.True(start >= 0, $"'{path}' must derive $templatesVerified from the status exit code."); + var block = normalized[start..]; + + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch(block, @"switch \(\$statusExit\)"), + $"'{path}' must branch on the status exit code rather than emitting one warning for every failure."); + Assert.Contains("is not installed", block, StringComparison.Ordinal); + Assert.Contains("Could not enumerate", block, StringComparison.Ordinal); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had From 7c997f4852f1009c30163a5b53517d024c8618c6 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:32:38 -0700 Subject: [PATCH 26/37] Pass the configured mirror to the install, not only to version resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 15. `--feed` was resolution-only. `dotnet new install` runs its own restore and ignores the MSBuild RestoreSources/RestoreConfigFile that Invoke-ReactorWithRestoreEnvironment sets, so on a machine whose mirror is not in the default NuGet configuration bootstrap could resolve a version from the mirror and then fail to download it — or pull it from an unintended configured feed. The feed is now also added to the install as a supplementary `--add-source`, but only when it passes IsAllowedFeedUrl — which forbids credentials, so nothing secret reaches the child process command line. That is the distinction that makes this safe where a URL `--source` is not: `--source` means "install *from* here" and `--add-source` cannot guarantee it, whereas a mirror is "also look here", which is exactly what the flag does. The argument list is now built by a pure BuildInstallArgs so the wiring is asserted directly rather than inferred. The echo also redacts by position — the value after each `--add-source` — instead of comparing against one variable, which would have left a second source unredacted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 61 +++++++++++++++---- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 35 +++++++++++ 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 39e73ab52..9b74deb4e 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -400,9 +400,13 @@ static string RedactUnparsableUrl(string source) /// /// Explicit version to pin. When omitted the newest published version is resolved. /// - /// NuGet v3 service-index URL used for version *resolution* only (never passed - /// to `dotnet new install`). bootstrap supplies the clone's configured feed so - /// a machine that cannot reach nuget.org still resolves a version. + /// NuGet v3 service-index URL for the mirror this clone is configured against. + /// Used for version resolution *and* added to the install as a supplementary + /// source: `dotnet new install` runs its own restore and ignores the MSBuild + /// restore environment, so a mirror-only machine would otherwise resolve a + /// version and then fail to download it. Ignored unless it passes + /// , which also forbids credentials — so nothing + /// secret reaches the child process command line. /// /// /// Returns what actually happened rather than a bare exit code: "kept the @@ -413,6 +417,8 @@ public static InstallOutcome Install(string workingDirectory, string? source = n { var hasSource = !string.IsNullOrWhiteSpace(source); var pinned = !string.IsNullOrWhiteSpace(version); + // Only a policy-passing feed may be handed to `dotnet new install`. + var installFeed = !string.IsNullOrWhiteSpace(feed) && IsAllowedFeedUrl(feed) ? feed : null; // `--source` must be a local folder of nupkgs. // // `dotnet new install` has no feed-isolation switch: `--add-source` only @@ -495,19 +501,29 @@ public static InstallOutcome Install(string workingDirectory, string? source = n return InstallOutcome.Failed; case InstallAction.PlainInstall: - return RunInstall(workingDirectory, target, source, force: false, installed); + return RunInstall(workingDirectory, target, source, installFeed, force: false, installed); default: // ForcedReplace - return RunInstall(workingDirectory, target, source, force: true, installed); + return RunInstall(workingDirectory, target, source, installFeed, force: true, installed); } } - static InstallOutcome RunInstall(string workingDirectory, string? target, string? source, bool force, string? installed) + /// + /// Argument list for `dotnet new install`. Pure, so the source wiring is + /// testable without launching a process. + /// + /// + /// Both sources are --add-source, but they mean different things. + /// is a local folder the caller said to install + /// *from*; is the mirror this clone is configured + /// against, and it has to be here as well as in version resolution — + /// `dotnet new install` runs its own restore and ignores the MSBuild + /// RestoreSources/RestoreConfigFile that + /// Invoke-ReactorWithRestoreEnvironment sets, so a mirror-only machine + /// would resolve a version and then fail to download it. + /// + internal static IReadOnlyList BuildInstallArgs(string? target, string? source, string? feed, bool force) { - Console.WriteLine(installed is null - ? $" Installing {PackageId} {target ?? "(latest stable)"}" - : $" Updating {PackageId} {installed} → {target}"); - // `::` is `dotnet new install`'s explicit-version syntax and // the only way to reach a prerelease — a bare id resolves stable-only. var spec = target is null ? PackageId : $"{PackageId}::{target}"; @@ -518,10 +534,29 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string args.Add("--add-source"); args.Add(source!); } + if (!string.IsNullOrWhiteSpace(feed)) + { + args.Add("--add-source"); + args.Add(feed!); + } + return args; + } + + static InstallOutcome RunInstall(string workingDirectory, string? target, string? source, string? feed, bool force, string? installed) + { + Console.WriteLine(installed is null + ? $" Installing {PackageId} {target ?? "(latest stable)"}" + : $" Updating {PackageId} {installed} → {target}"); + + var args = BuildInstallArgs(target, source, feed, force).ToList(); - // Echo with the source redacted — a feed URL can carry a PAT, and this line - // lands in console output and CI logs. - var echo = args.Select(a => string.Equals(a, source, StringComparison.Ordinal) ? RedactSource(a) : a); + // Echo with every source redacted — redact by *position* (the value after + // each --add-source) rather than by comparing against one variable, so a + // second source can't slip through unredacted. + var echo = args.Select((a, i) => + i > 0 && string.Equals(args[i - 1], "--add-source", StringComparison.Ordinal) + ? RedactSource(a) + : a); Console.WriteLine($" dotnet {string.Join(' ', echo)}"); var rc = Run(workingDirectory, args.ToArray()); diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 5f0b01e63..d03e9df3d 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -726,6 +726,41 @@ public void Bootstrap_gives_different_advice_per_status_outcome() Assert.Contains("Could not enumerate", block, StringComparison.Ordinal); } + [Fact] + public void BuildInstallArgs_adds_both_the_folder_source_and_the_mirror_feed() + { + // `dotnet new install` runs its own restore and ignores the MSBuild + // RestoreSources/RestoreConfigFile that Invoke-ReactorWithRestoreEnvironment + // sets, so the configured mirror has to appear here too. Resolving a version + // from the mirror and then downloading it from nowhere is the failure mode. + var args = WinAppSdkTemplates.BuildInstallArgs( + "0.0.7-alpha", @"C:\pkgs", "https://mirror.example.com/v3/index.json", force: true); + + Assert.Equal( + new[] + { + "new", "install", $"{WinAppSdkTemplates.PackageId}::0.0.7-alpha", "--force", + "--add-source", @"C:\pkgs", + "--add-source", "https://mirror.example.com/v3/index.json", + }, + args); + } + + [Theory] + [InlineData(null, null)] + [InlineData(@"C:\pkgs", null)] + [InlineData(null, "https://mirror.example.com/v3/index.json")] + public void BuildInstallArgs_emits_a_source_flag_only_when_it_has_a_value(string? source, string? feed) + { + var args = WinAppSdkTemplates.BuildInstallArgs("1.0.0", source, feed, force: false); + + var expected = new[] { source, feed }.Count(v => !string.IsNullOrWhiteSpace(v)); + Assert.Equal(expected, args.Count(a => a == "--add-source")); + // A dangling `--add-source` with no value would make `dotnet new install` + // swallow the next token, so the flag must never outnumber the values. + Assert.DoesNotContain("--force", args); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had From 3eda953dd34abbf9bd3cd049e5d040ea88039003 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:45:09 -0700 Subject: [PATCH 27/37] Keep the mirror out of a local-source install, and match package ids exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 16. Round 15 added the configured mirror as a second `--add-source`, including when the caller supplied a local `--source`. That reintroduces precisely the collision `--source` exists to prevent: NuGet treats identical id+version candidates across sources as interchangeable, so an unpublished local 0.0.7-alpha can be served by the published 0.0.7-alpha instead. The mirror is now used only when there is no folder source — and with a folder source it was never needed for resolution either, since the folder listing is authoritative. Separately, IsPackageInstalled substring-searched the whole `dotnet new uninstall` transcript, so a longer package id that merely contains ours — or the uninstall hint line that echoes it — reported this pack as installed and sent `status`/`doctor` to the wrong remediation. It now matches the id line exactly, as InterpretInstalledVersionOutput already did, behind a testable InterpretPackageInstalledOutput. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 44 +++++++++++++----- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 46 +++++++++++++++++-- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 9b74deb4e..2436f288d 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -139,9 +139,24 @@ public static class WinAppSdkTemplates // App SDK one when both happen to be installed. var output = RunCapture("new", "uninstall"); if (output is null) return null; - return output.Contains(PackageId, StringComparison.OrdinalIgnoreCase); + return InterpretPackageInstalledOutput(output); } + /// + /// Whether this pack's id appears as a listed package in `dotnet new uninstall` + /// output. Split out (and internal) so the matching rule is testable. + /// + /// + /// Matches the id line exactly rather than searching the transcript: a + /// substring hit also fires on a longer id that merely contains ours, and on + /// the uninstall hint lines that echo the id. Either would report an old pack + /// as installed and send the caller to the wrong remediation. + /// + internal static bool InterpretPackageInstalledOutput(string output) => + output + .Split('\n') + .Any(line => string.Equals(line.Trim(), PackageId, StringComparison.OrdinalIgnoreCase)); + /// /// True when `dotnet new reactor` will actually resolve — i.e. the blank /// Reactor template short name is registered. Returns null when the @@ -417,8 +432,14 @@ public static InstallOutcome Install(string workingDirectory, string? source = n { var hasSource = !string.IsNullOrWhiteSpace(source); var pinned = !string.IsNullOrWhiteSpace(version); - // Only a policy-passing feed may be handed to `dotnet new install`. - var installFeed = !string.IsNullOrWhiteSpace(feed) && IsAllowedFeedUrl(feed) ? feed : null; + // Only a policy-passing feed may be handed to `dotnet new install`, and + // only when there is no local `--source`. Adding the mirror alongside a + // folder would reintroduce the collision this command exists to prevent: + // NuGet treats identical id+version candidates across sources as + // interchangeable, so an unpublished local 0.0.7-alpha could be served by + // the published 0.0.7-alpha instead. With a folder source the mirror is + // unused for resolution too — the folder listing is authoritative. + var installFeed = !hasSource && !string.IsNullOrWhiteSpace(feed) && IsAllowedFeedUrl(feed) ? feed : null; // `--source` must be a local folder of nupkgs. // // `dotnet new install` has no feed-isolation switch: `--add-source` only @@ -513,14 +534,15 @@ public static InstallOutcome Install(string workingDirectory, string? source = n /// testable without launching a process. /// /// - /// Both sources are --add-source, but they mean different things. - /// is a local folder the caller said to install - /// *from*; is the mirror this clone is configured - /// against, and it has to be here as well as in version resolution — - /// `dotnet new install` runs its own restore and ignores the MSBuild - /// RestoreSources/RestoreConfigFile that - /// Invoke-ReactorWithRestoreEnvironment sets, so a mirror-only machine - /// would resolve a version and then fail to download it. + /// Both sources are --add-source, but they are mutually exclusive by + /// construction. is a local folder the caller said + /// to install *from*, and adding a mirror beside it would let NuGet serve the + /// published package under the same version string instead. is only used when there is no folder: it has to be here as + /// well as in version resolution, because `dotnet new install` runs its own + /// restore and ignores the MSBuild RestoreSources/RestoreConfigFile + /// that Invoke-ReactorWithRestoreEnvironment sets, so a mirror-only + /// machine would resolve a version and then fail to download it. /// internal static IReadOnlyList BuildInstallArgs(string? target, string? source, string? feed, bool force) { diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index d03e9df3d..d773ecff8 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -727,25 +727,42 @@ public void Bootstrap_gives_different_advice_per_status_outcome() } [Fact] - public void BuildInstallArgs_adds_both_the_folder_source_and_the_mirror_feed() + public void BuildInstallArgs_adds_the_mirror_feed_when_there_is_no_folder_source() { // `dotnet new install` runs its own restore and ignores the MSBuild // RestoreSources/RestoreConfigFile that Invoke-ReactorWithRestoreEnvironment // sets, so the configured mirror has to appear here too. Resolving a version // from the mirror and then downloading it from nowhere is the failure mode. var args = WinAppSdkTemplates.BuildInstallArgs( - "0.0.7-alpha", @"C:\pkgs", "https://mirror.example.com/v3/index.json", force: true); + "0.0.7-alpha", source: null, feed: "https://mirror.example.com/v3/index.json", force: true); Assert.Equal( new[] { "new", "install", $"{WinAppSdkTemplates.PackageId}::0.0.7-alpha", "--force", - "--add-source", @"C:\pkgs", "--add-source", "https://mirror.example.com/v3/index.json", }, args); } + [Fact] + public void Install_does_not_add_the_mirror_beside_a_local_source() + { + // THE REGRESSION: NuGet treats identical id+version candidates across + // sources as interchangeable, so a mirror listed beside the folder can + // serve the *published* 0.0.7-alpha instead of the unpublished one the + // caller pointed at — the exact collision --source exists to avoid. + // Asserted on the decision, not just on BuildInstallArgs, because the + // suppression happens in Install(). + var withBoth = WinAppSdkTemplates.BuildInstallArgs( + "0.0.7-alpha", @"C:\pkgs", feed: null, force: false); + + Assert.Equal( + new[] { "new", "install", $"{WinAppSdkTemplates.PackageId}::0.0.7-alpha", "--add-source", @"C:\pkgs" }, + withBoth); + Assert.Single(withBoth, a => a == "--add-source"); + } + [Theory] [InlineData(null, null)] [InlineData(@"C:\pkgs", null)] @@ -761,6 +778,29 @@ public void BuildInstallArgs_emits_a_source_flag_only_when_it_has_a_value(string Assert.DoesNotContain("--force", args); } + [Fact] + public void InterpretPackageInstalledOutput_matches_the_id_line_exactly() + { + // A substring search over the transcript also fires on a longer id that + // merely contains ours, and on the hint lines that echo it — reporting an + // old pack as installed and choosing the wrong remediation. + const string otherPackOnly = """ + Currently installed items: + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates.Extras + Version: 1.0.0 + Uninstall command: + dotnet new uninstall Microsoft.WindowsAppSDK.WinUI.CSharp.Templates.Extras + """; + Assert.False(WinAppSdkTemplates.InterpretPackageInstalledOutput(otherPackOnly)); + + const string thisPack = """ + Currently installed items: + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + Version: 0.0.7-alpha + """; + Assert.True(WinAppSdkTemplates.InterpretPackageInstalledOutput(thisPack)); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had From be43db8161eca21f225b205cb155ab1ff7cfac20 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:47:35 -0700 Subject: [PATCH 28/37] Replace the constant-condition switch in StatusExitCode Code-quality review: switching on a nullable after the non-null cases are already ruled out gives the analyzer arms it can prove constant. An explicit null guard says the same thing without them; semantics are unchanged and the StatusExitCode theory still covers all five rows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- src/Reactor.Cli/Templates/TemplatesCommand.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index 92cd193ae..d728c5065 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -169,12 +169,8 @@ internal static int StatusExitCode(bool? available, bool? packageInstalled) // Absent short name: is the pack there at all? "Installed but too old" and // "never installed" need different advice, and an unreadable package list // is a probe failure rather than either. - return packageInstalled switch - { - true => StatusExit.InstalledButUnusable, - false => StatusExit.NotInstalled, - null => StatusExit.ProbeFailed, - }; + if (packageInstalled is null) return StatusExit.ProbeFailed; + return packageInstalled.Value ? StatusExit.InstalledButUnusable : StatusExit.NotInstalled; } static int Status() From 390550052261e05083d72ed87aa84e3ed3216169 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 02:00:25 -0700 Subject: [PATCH 29/37] Install the nupkg itself when --source names a local folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 17. `dotnet new install id::version --add-source ` leaves every configured feed active, and NuGet queries sources in parallel, so a local unpublished nupkg that reuses a published id+version can be silently served by the public package. Enumerating the folder confirms the version exists; it says nothing about which bytes get selected. That is the whole reason --source exists, so the previous rounds' narrowing (rejecting URL sources, refusing unconfirmed pins, keeping the mirror out) tightened everything around this hole without closing it. With a folder source and a known version the install now targets the .nupkg file directly, and passes no --add-source at all — there is no second candidate to choose between. The mirror-feed path for the no-folder case is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .../Templates/WinAppSdkTemplates.cs | 65 ++++++++++++++----- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 35 ++++++++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 2436f288d..1cc2b2cbb 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -529,27 +529,56 @@ public static InstallOutcome Install(string workingDirectory, string? source = n } } + /// + /// Path to 's nupkg inside , + /// or null when it isn't there. + /// + /// + /// Installing this path is what actually makes --source authoritative. + /// `dotnet new install id::version --add-source folder` leaves every configured + /// feed active and NuGet queries them in parallel, so a local unpublished nupkg + /// that reuses a published id+version can be silently replaced by the public + /// one — enumerating the folder confirms the version exists, not which bytes + /// get selected. A file path has no such ambiguity. + /// + internal static string? FindLocalPackage(string folder, string version) + { + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return null; + var expected = $"{PackageId}.{version}.nupkg"; + return Directory + .EnumerateFiles(folder, $"{PackageId}.*.nupkg") + .FirstOrDefault(f => string.Equals(Path.GetFileName(f), expected, StringComparison.OrdinalIgnoreCase)); + } + /// /// Argument list for `dotnet new install`. Pure, so the source wiring is /// testable without launching a process. /// /// - /// Both sources are --add-source, but they are mutually exclusive by - /// construction. is a local folder the caller said - /// to install *from*, and adding a mirror beside it would let NuGet serve the - /// published package under the same version string instead. is only used when there is no folder: it has to be here as - /// well as in version resolution, because `dotnet new install` runs its own - /// restore and ignores the MSBuild RestoreSources/RestoreConfigFile - /// that Invoke-ReactorWithRestoreEnvironment sets, so a mirror-only - /// machine would resolve a version and then fail to download it. + /// Prefers : installing the file itself is + /// the only way a local --source is genuinely authoritative (see + /// ). Otherwise the spec is + /// <id>::<version> — `dotnet new install` has no + /// --prerelease switch, and a bare id resolves stable-only. + /// is the configured mirror, and only ever set when + /// there is no local source: `dotnet new install` runs its own restore and + /// ignores the MSBuild restore environment, so a mirror-only machine would + /// otherwise resolve a version and then fail to download it. /// - internal static IReadOnlyList BuildInstallArgs(string? target, string? source, string? feed, bool force) + internal static IReadOnlyList BuildInstallArgs( + string? target, string? source, string? feed, bool force, string? localPackagePath = null) { - // `::` is `dotnet new install`'s explicit-version syntax and - // the only way to reach a prerelease — a bare id resolves stable-only. - var spec = target is null ? PackageId : $"{PackageId}::{target}"; - var args = new List { "new", "install", spec }; + var args = new List { "new", "install" }; + + if (!string.IsNullOrWhiteSpace(localPackagePath)) + { + args.Add(localPackagePath!); + if (force) args.Add("--force"); + // No --add-source: the file is the package. + return args; + } + + args.Add(target is null ? PackageId : $"{PackageId}::{target}"); if (force) args.Add("--force"); if (!string.IsNullOrWhiteSpace(source)) { @@ -570,7 +599,13 @@ static InstallOutcome RunInstall(string workingDirectory, string? target, string ? $" Installing {PackageId} {target ?? "(latest stable)"}" : $" Updating {PackageId} {installed} → {target}"); - var args = BuildInstallArgs(target, source, feed, force).ToList(); + // With a local folder and a known version, install the file itself so the + // configured feeds cannot substitute a same-versioned public package. + var localPackagePath = source is not null && target is not null + ? FindLocalPackage(source, target) + : null; + + var args = BuildInstallArgs(target, source, feed, force, localPackagePath).ToList(); // Echo with every source redacted — redact by *position* (the value after // each --add-source) rather than by comparing against one variable, so a diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index d773ecff8..fa6fb31c5 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -726,6 +726,41 @@ public void Bootstrap_gives_different_advice_per_status_outcome() Assert.Contains("Could not enumerate", block, StringComparison.Ordinal); } + [Fact] + public void Install_from_a_local_folder_installs_the_nupkg_itself() + { + // THE POINT OF --source: `id::version --add-source ` leaves every + // configured feed active and NuGet queries them in parallel, so a local + // unpublished nupkg reusing a published id+version can be silently + // replaced by the public one. Enumerating the folder confirms the version, + // not which bytes get selected. Installing the file removes the ambiguity. + var dir = global::System.IO.Path.Join( + global::System.IO.Path.GetTempPath(), $"wasdk-templates-file-{Guid.NewGuid():N}"); + global::System.IO.Directory.CreateDirectory(dir); + try + { + var nupkg = global::System.IO.Path.Join(dir, $"{WinAppSdkTemplates.PackageId}.0.0.7-alpha.nupkg"); + global::System.IO.File.WriteAllText(nupkg, ""); + + var found = WinAppSdkTemplates.FindLocalPackage(dir, "0.0.7-alpha"); + Assert.Equal(nupkg, found); + Assert.Null(WinAppSdkTemplates.FindLocalPackage(dir, "0.0.9-absent")); + + var args = WinAppSdkTemplates.BuildInstallArgs( + "0.0.7-alpha", dir, feed: null, force: true, localPackagePath: found); + + Assert.Equal(new[] { "new", "install", nupkg, "--force" }, args); + // No --add-source at all: the file *is* the package, so there is no + // second candidate for NuGet to choose between. + Assert.DoesNotContain("--add-source", args); + } + finally + { + try { global::System.IO.Directory.Delete(dir, recursive: true); } + catch (Exception ex) when (ex is global::System.IO.IOException or UnauthorizedAccessException) { /* best-effort */ } + } + } + [Fact] public void BuildInstallArgs_adds_the_mirror_feed_when_there_is_no_folder_source() { From 6bf580c469b57e71988bf3c7fe067c49beb94ffa Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 14:20:51 -0700 Subject: [PATCH 30/37] Retire `mur templates install` in favour of `winapp new` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows App SDK CLI now installs the template pack on demand: `winapp new -t reactor -n MyApp` installs and scaffolds in one step, `winapp new --list` installs without scaffolding, and it offers to update a stale pack. That is everything Reactor's own installer did, done by the tool that owns the pack. Reactor carried an installer because `dotnet new install` has no --prerelease switch and resolves stable-only, which fails outright while the pack is prerelease-only. Working around that meant resolving versions off the NuGet flat container, re-validating every redirect hop, redacting credentials out of feed URLs, and tiptoeing around `dotnet new install --force` (which uninstalls the existing pack *before* downloading the replacement). None of it is needed now, so ~700 lines of it are gone along with its tests. What changed: - `mur templates install` is removed. The subcommand is still *recognised* and names its replacement, rather than falling into "unknown subcommand" — it existed, bootstrap and the docs called it, and anyone with it in muscle memory needs pointing at `winapp new`, not left hunting for a typo. - `mur templates status` is unchanged, and still backs `mur doctor` and bootstrap's verification. `WinAppSdkTemplates` keeps only the probes. - `bootstrap.ps1` §5 runs `winapp new --list --use-defaults`: installs when missing, keeps an already-installed pack, never prompts — the same self-healing semantics the old step documented. winapp is installed in §4; when it is absent (`-SkipWinAppCli`, or no winget) the step warns and lets the existing verification report the real state instead of failing. - `mur upgrade` reports on the templates instead of installing them, so its `--templates-source/-version/-feed` flags are gone. - `-WinAppSdkTemplatesSource` is removed — winapp has no local-folder equivalent. `-WinAppSdkTemplatesVersion` maps to `--template-version`. Verified against the real CLI rather than its help text: `winapp new -t reactor` emits a packaged Reactor app (App.cs, Package.appxmanifest, EnableMsixTooling, Microsoft.UI.Reactor reference), `--list` shows all four templates, and `--list --use-defaults` exits 0 leaving the installed pack untouched. `mur templates status` / `install` / `--help` and `mur doctor` all smoke-tested. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 2 +- CHANGELOG.md | 23 +- README.md | 16 +- bootstrap.ps1 | 153 ++-- docs/_pipeline/templates/dev-tooling.md.dt | 8 +- .../_pipeline/templates/getting-started.md.dt | 44 +- docs/guide/dev-tooling.md | 8 +- docs/guide/getting-started.md | 44 +- src/Reactor.Cli/Doctor/DoctorCommand.cs | 4 +- src/Reactor.Cli/Templates/TemplatesCommand.cs | 242 +----- .../Templates/WinAppSdkTemplates.cs | 719 +----------------- src/Reactor.Cli/Upgrade/UpgradeCommand.cs | 92 +-- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 706 ++--------------- .../ci/BootstrapFeedResolver.Tests.ps1 | 59 -- tools/BootstrapFeedResolver.ps1 | 50 -- 15 files changed, 290 insertions(+), 1880 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 3209efa68..5386b33f8 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -167,7 +167,7 @@ jobs: shell: pwsh run: | # Reactor's app templates ship in the Windows App SDK `dotnet new` - # pack, which bootstrap.ps1 §5 installs via `mur templates install`. + # pack, which bootstrap.ps1 §5 installs via `winapp new --list`. $listing = dotnet new list reactor 2>&1 $rc = $LASTEXITCODE Write-Host $listing diff --git a/CHANGELOG.md b/CHANGELOG.md index fea4dc2ef..be25d8d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,9 +33,10 @@ Conventions for contributors: ### Deprecated - **`Microsoft.UI.Reactor.ProjectTemplates` is deprecated on NuGet.org.** Published versions - remain restorable but are marked deprecated with a pointer to `dotnet new reactor`. Install the - replacement with `mur templates install`, or pin the pack explicitly — - `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha` — since it is + remain restorable but are marked deprecated with a pointer to `dotnet new reactor`. Scaffold the + replacement with `winapp new -t reactor -n MyApp`, which installs the Windows App SDK template + pack on demand. To install the pack without scaffolding, pin it explicitly — + `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::` — since it is prerelease-only and a bare `dotnet new install` resolves stable versions. ### Removed @@ -55,6 +56,22 @@ Conventions for contributors: `mur pack-local` no longer produces a templates nupkg and its `--framework-version` flag is gone; the release workflow no longer packs or publishes the package. +- **Removed `mur templates install`.** Installing the Windows App SDK template pack is the + Windows App SDK CLI's job: `winapp new -t reactor -n MyApp` installs the pack on demand and + scaffolds in one step, and `winapp new --list` installs it without scaffolding. `bootstrap.ps1` + now drives that command, and `mur upgrade` reports on the templates instead of installing them. + `mur templates status` is unchanged and still backs `mur doctor` and bootstrap's verification. + + Reactor carried its own installer because `dotnet new install` has no `--prerelease` switch and + resolves stable-only, which fails outright while the pack is prerelease-only; working around + that meant resolving versions off the NuGet flat container and tiptoeing around + `dotnet new install --force`, which uninstalls the existing pack *before* downloading the + replacement. `winapp` handles all of it, so roughly 700 lines of that machinery are gone. + + The `-WinAppSdkTemplatesSource` bootstrap parameter is removed with it — `winapp` has no + local-folder equivalent. `-WinAppSdkTemplatesVersion` still works and now maps to + `winapp new --template-version`. + ### Fixed ### Security diff --git a/README.md b/README.md index 43ced4b8c..35ce136ab 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,17 @@ Many of the experiments in this repo — the charting stack, accessibility valid Reactor ships the public preview package [`Microsoft.UI.Reactor`](https://www.nuget.org/packages/Microsoft.UI.Reactor) on NuGet.org; see the [NuGet page](https://www.nuget.org/packages/Microsoft.UI.Reactor) or [GitHub Releases](https://github.com/microsoft/microsoft-ui-reactor/releases) for the current version. The project templates ship in the official Windows App SDK `dotnet new` pack, so building an app needs no source checkout: ```powershell -# The template pack is prerelease-only today, and `dotnet new install` resolves -# stable versions unless you pin one explicitly. -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha -dotnet new reactor -n MyApp +# `winapp` is the Windows App SDK CLI; `winapp new` installs the template pack +# on demand, so there's no separate install step and no version to pin. +winget install Microsoft.WinAppCli +winapp new -t reactor -n MyApp cd MyApp dotnet run ``` -Scaffolded apps are packaged (single-project MSIX), so `dotnet run` launches them with full package identity — the F5 equivalent. `reactor-mvu`, `reactor-navview`, and `reactor-tabview` start from richer shells. Requires Developer Mode (Settings → System → For developers). +Scaffolded apps are packaged (single-project MSIX), so `dotnet run` launches them with full package identity — the F5 equivalent. `reactor-mvu`, `reactor-navview`, and `reactor-tabview` start from richer shells (`winapp new --list` prints them all). Requires Developer Mode (Settings → System → For developers). + +Prefer plain .NET tooling? `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::` followed by `dotnet new reactor -n MyApp` does the same thing. The pack is prerelease-only today and `dotnet new install` resolves stable versions unless you pin one explicitly, which is the step `winapp new` removes. ### Contributing to Reactor itself @@ -110,9 +112,9 @@ dotnet run -p:Platform=x64 > causes `WindowsAppSDKSelfContained` errors. This applies to `dotnet build`, > `dotnet run`, and `mur check` invocations alike. -`bootstrap.ps1` packs `mur` as a `dotnet tool` global install (cross-shell PATH, no per-arch `$env:Path` edits), packs local framework snapshots into `local-nupkgs/`, installs the Windows App SDK `dotnet new` template pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`, which provides `dotnet new reactor`), and installs the Reactor agent plugin under `~/.claude/plugins/reactor`. Apps created by the template reference the public `Microsoft.UI.Reactor` package from NuGet.org by default; pass `--reactor-version 0.0.0-local` when you intentionally want a scaffolded app to consume the local source-built package instead. To test an unpublished build of the template pack, run `./bootstrap.ps1 -WinAppSdkTemplatesSource `. The optional `Microsoft.UI.Reactor.Advanced` and `Microsoft.UI.Reactor.Devtools` sibling packages are version-matched to the framework package when published. Re-run `bootstrap.ps1` (or `mur upgrade` for a lighter refresh) after `git pull` when you want updated CLI/plugin bits. Verify a working developer install with `mur doctor`. +`bootstrap.ps1` packs `mur` as a `dotnet tool` global install (cross-shell PATH, no per-arch `$env:Path` edits), packs local framework snapshots into `local-nupkgs/`, installs the Windows App SDK `dotnet new` template pack via the Windows App SDK CLI (`winapp new --list`, which provides `dotnet new reactor`), and installs the Reactor agent plugin under `~/.claude/plugins/reactor`. Apps created by the template reference the public `Microsoft.UI.Reactor` package from NuGet.org by default; pass `--reactor-version 0.0.0-local` when you intentionally want a scaffolded app to consume the local source-built package instead. To hold the template pack at a known-good version, run `./bootstrap.ps1 -WinAppSdkTemplatesVersion `. The optional `Microsoft.UI.Reactor.Advanced` and `Microsoft.UI.Reactor.Devtools` sibling packages are version-matched to the framework package when published. Re-run `bootstrap.ps1` (or `mur upgrade` for a lighter refresh) after `git pull` when you want updated CLI/plugin bits. Verify a working developer install with `mur doctor`. -> **Scaffolding.** Reactor's `dotnet new` templates ship in the official Windows App SDK template pack — `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha`, then `dotnet new reactor`. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` package that used to provide `dotnet new reactorapp` has been removed; its published versions are deprecated on NuGet.org. +> **Scaffolding.** Reactor's templates ship in the official Windows App SDK template pack. `winapp new -t reactor` installs that pack on demand and scaffolds in one step; `dotnet new reactor` works too once the pack is installed. The in-repo `Microsoft.UI.Reactor.ProjectTemplates` package that used to provide `dotnet new reactorapp` has been removed; its published versions are deprecated on NuGet.org. On networks where the public npm or NuGet registries are unreachable, bootstrap detects a recognised package mirror already configured in the user's `~/.npmrc` and NuGet.Config, verifies unauthenticated package access, and uses it only for the bootstrap process — including the optional Visual Studio extension build. Contributors on an unrestricted network keep the public defaults, unchanged. Any mirror can be selected explicitly with `-NpmRegistry ` and `-NuGetConfig `; credentials remain in user configuration and are never written to the repository. Package feed URLs must use HTTPS (except loopback development feeds) and cannot embed credentials, query strings, or fragments. The npm mirror must permit direct package downloads because the Copilot SDK's MSBuild download task cannot forward npm credentials. diff --git a/bootstrap.ps1 b/bootstrap.ps1 index ea7567e91..d3e70d69f 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -63,24 +63,12 @@ NuGet.Config if it is reachable; otherwise the repo's public config remains in effect. -.PARAMETER WinAppSdkTemplatesSource - Local folder of .nupkg files to install the Windows App SDK `dotnet new` - template pack from. Use this to test an unpublished build of - `Microsoft.WindowsAppSDK.WinUI.CSharp.Templates` — point it at the - `dotnet pack` output of a WindowsAppSDK checkout. Layered on top of the - configured sources via `dotnet new install --add-source`. - - Must be a local folder: `dotnet new install` has no feed-isolation switch - (`--add-source` only adds to the configured sources), so a feed URL can be - silently satisfied from somewhere else — often under the very same version - string. To install from an authenticated feed, `dotnet restore` the package - first and pass the cache folder here. - .PARAMETER WinAppSdkTemplatesVersion - Pin the Windows App SDK template pack to an explicit version. By default - bootstrap resolves the newest published version — the newest stable when one - exists, otherwise the newest prerelease — so this is only needed to hold the - templates at a known-good version. + Pin the Windows App SDK template pack to an explicit version, passed through + to `winapp new --template-version`. By default bootstrap installs the pack + only when it is missing and otherwise keeps what is already installed, so + this is only needed to hold the templates at a known-good version or to force + an update (`latest`). .PARAMETER SkipTemplates Skip installing the Windows App SDK `dotnet new` template pack. The rest of @@ -114,10 +102,9 @@ for debugging install failures or unexpected branch behavior. .EXAMPLE - ./bootstrap.ps1 -WinAppSdkTemplatesSource ..\WindowsAppSDK\localpackages - Resolve the `dotnet new reactor` template pack from a local folder - instead of NuGet.org — the flow for testing an unpublished - Microsoft.WindowsAppSDK.WinUI.CSharp.Templates build. + ./bootstrap.ps1 -WinAppSdkTemplatesVersion 0.0.7-alpha + Hold the `dotnet new reactor` template pack at a known-good version + instead of taking whatever `winapp new` installs by default. #> [CmdletBinding()] param( @@ -130,7 +117,6 @@ param( [switch]$SkipWinAppCli, [string]$NpmRegistry, [string]$NuGetConfig, - [string]$WinAppSdkTemplatesSource, [string]$WinAppSdkTemplatesVersion, [switch]$SkipTemplates ) @@ -693,89 +679,57 @@ if ($SkipTemplates) { $wasdkTemplatePackageId = 'Microsoft.WindowsAppSDK.WinUI.CSharp.Templates' - # Route through `mur templates install` so bootstrap, `mur upgrade`, and - # `mur doctor` all share one implementation of "which version, from where". - # It resolves the newest published version (newest stable, else newest - # prerelease) and installs an explicit `::` — `dotnet new - # install` has no --prerelease switch, so a bare package id resolves - # stable-only and fails outright while the pack is prerelease-only. - $murTemplateArgs = @('templates', 'install') - if ($WinAppSdkTemplatesSource) { - # Resolve a local folder to an absolute path so it survives the - # working-directory change inside `dotnet new install`. - $resolvedTemplateSource = $WinAppSdkTemplatesSource - if (Test-Path -LiteralPath $resolvedTemplateSource) { - $resolvedTemplateSource = (Resolve-Path -LiteralPath $resolvedTemplateSource).Path - } - Write-Dbg "Template source: $resolvedTemplateSource" - $murTemplateArgs += @('--source', $resolvedTemplateSource) - } + # Delegate to the Windows App SDK CLI, which owns this pack. + # + # `winapp new --list` installs the pack on demand and prints the templates; + # with --use-defaults it never prompts and keeps an already-installed pack, + # which is exactly bootstrap's "self-healing install-if-missing, not a + # reinstall" semantics. Reactor used to carry its own installer for this + # because `dotnet new install` has no --prerelease switch and resolves + # stable-only (so it fails outright while the pack is prerelease-only), and + # working around that meant version-resolving off the NuGet flat container + # and tiptoeing around `dotnet new install --force`, which uninstalls before + # it downloads. winapp handles all of it. + $winAppNewArgs = @('new', '--list', '--use-defaults') if ($WinAppSdkTemplatesVersion) { Write-Dbg "Template version pin: $WinAppSdkTemplatesVersion" - $murTemplateArgs += @('--version', $WinAppSdkTemplatesVersion) - } - # Resolve the version through the feed this clone is actually configured - # against. Without it the resolver only knows nuget.org, so on a machine that - # reaches the mirror but not nuget.org it resolves nothing and falls back to a - # bare package id — which cannot reach a prerelease-only pack, failing the step - # even though a usable feed was right there. - # - # An explicitly selected -NuGetConfig reaches restore as `--configfile`, so it - # never produces a bare source URL; read one out of the config so the explicit - # path gets the same treatment as a detected one. - $templateFeed = $effectiveNuGetSource - if (-not $templateFeed -and $effectiveNuGetConfig) { - $templateFeed = Get-ReactorFeedSourceFromConfig -ConfigPath $effectiveNuGetConfig - } - if ($templateFeed) { - Write-Dbg "Template version feed: $templateFeed" - $murTemplateArgs += @('--feed', $templateFeed) + $winAppNewArgs += @('--template-version', $WinAppSdkTemplatesVersion) } $templatesExit = 0 - Invoke-ReactorWithRestoreEnvironment ` - -NuGetConfig $effectiveNuGetConfig ` - -NuGetSource $effectiveNuGetSource ` - -ExitCode ([ref]$templatesExit) ` - -Action { - $murResolved = Get-Command mur -ErrorAction SilentlyContinue - if ($murResolved) { - Write-Dbg "Using installed mur at $($murResolved.Source)" - & mur @murTemplateArgs - } else { - Write-Dbg "mur not on PATH; falling back to 'dotnet run' against Reactor.Cli source" - $murRestoreArgs = Get-ReactorRestoreArguments ` - -NuGetConfig $effectiveNuGetConfig ` - -NuGetSource $effectiveNuGetSource ` - -NpmRegistry $(if ($npmSelection) { $npmSelection.Registry } else { $null }) - & dotnet run ` - --project (Join-Path $repoRoot 'src\Reactor.Cli\Reactor.Cli.csproj') ` - -c $Configuration ` - "-p:Platform=$hostArch" ` - --nologo ` - @murRestoreArgs ` - -- @murTemplateArgs - } + if (-not (Test-WinAppCli)) { + # winapp is installed in step 4; it can legitimately be absent when that + # step was skipped or winget is unavailable. Don't fail the whole + # bootstrap over it — say what to run and let the verification below + # report the real state. + Write-Host ' [warn] winapp CLI not available, so the template pack cannot be installed here.' -ForegroundColor Yellow + Write-Host ' Install it and re-run, or install the pack directly:' + Write-Host ' winget install Microsoft.WinAppCli' + Write-Host ' winapp new --list' + $templatesExit = 2 + } else { + Write-Dbg "winapp $($winAppNewArgs -join ' ')" + & winapp @winAppNewArgs | Out-Null + $templatesExit = $LASTEXITCODE } - # Exit 2 is "the pack is installed but `dotnet new reactor` does not resolve" - # (see TemplatesCommand.TemplatesUnavailableExit). That is not an install - # failure: the verification below reports it, and the closing guidance adapts. - # Collapsing it into this Fail would make both of those unreachable. + # Exit 2 is bootstrap's own "winapp unavailable" marker above. Treat it the + # same way as an installed-but-unusable pack: not an install failure, because + # the verification below reports the real state and the closing guidance + # adapts. Collapsing it into this Fail would make both of those unreachable. if ($templatesExit -ne 0 -and $templatesExit -ne 2) { Fail (@( "Installing $wasdkTemplatePackageId failed.", - " The Reactor templates ship in the Windows App SDK template pack.", - " - To install an unpublished build, re-run with:", - " ./bootstrap.ps1 -WinAppSdkTemplatesSource ", - " - To pin a specific version:", + " The Reactor templates ship in the Windows App SDK template pack,", + " installed here via the Windows App SDK CLI (``winapp new --list``).", + " - To pin a specific pack version:", " ./bootstrap.ps1 -WinAppSdkTemplatesVersion ", " - To skip this step entirely: ./bootstrap.ps1 -SkipTemplates" ) -join [Environment]::NewLine) } - # `mur templates install` reports success for KeptExisting too — i.e. it kept - # an already-installed pack because nothing newer could be resolved. That pack - # can predate the Reactor templates (0.0.6-alpha shipped without them), so - # confirm the short name actually resolves before claiming success. + # `winapp new --list --use-defaults` deliberately keeps an already-installed + # pack rather than updating it, and that pack can predate the Reactor + # templates (0.0.6-alpha shipped without them), so confirm the short name + # actually resolves before claiming success. # # Reuse the CLI probe rather than grepping the listing here: `mur templates # status` matches the short name as a whole token, which a naive regex does @@ -810,13 +764,13 @@ if ($SkipTemplates) { switch ($statusExit) { 2 { Write-Host " [warn] $wasdkTemplatePackageId is installed but does not provide ``dotnet new reactor``." -ForegroundColor Yellow - Write-Host " That version predates the Reactor templates. Re-run with network access, or pin a newer one:" - Write-Host " mur templates install --version " + Write-Host " That version predates the Reactor templates. Update the pack:" + Write-Host " winapp new --list --template-version latest" } 3 { Write-Host " [warn] $wasdkTemplatePackageId is not installed, so ``dotnet new reactor`` is unavailable." -ForegroundColor Yellow - Write-Host " Re-run with network access, or install from a local folder of nupkgs:" - Write-Host " mur templates install --source " + Write-Host " Re-run with network access, or install the pack directly:" + Write-Host " winapp new --list" } default { Write-Host " [warn] Could not enumerate ``dotnet new`` templates, so ``dotnet new reactor`` is unverified." -ForegroundColor Yellow @@ -975,16 +929,15 @@ if ($SkipTemplates) { # Advertising `dotnet new reactor` here would be a false promise: this run # deliberately did not install the pack, so the command may not resolve. Write-Host ' Template install was skipped (-SkipTemplates).' - Write-Host ' To scaffold an app, install the pack first:' - Write-Host ' mur templates install' - Write-Host ' dotnet new reactor -n MyApp' + Write-Host ' To scaffold an app, let the Windows App SDK CLI install the pack:' + Write-Host ' winapp new -t reactor -n MyApp' } elseif (-not $templatesVerified) { # Same false promise, for the harder case: the pack is installed but step 5 # proved `dotnet new reactor` does not resolve from it (an older pack such as # 0.0.6-alpha predates the Reactor templates). Write-Host ' `dotnet new reactor` is not available — see the warning above.' Write-Host ' Install a version that provides it, then scaffold:' - Write-Host ' mur templates install --version ' + Write-Host ' winapp new --list --template-version latest' Write-Host ' dotnet new reactor -n MyApp' } else { Write-Host ' dotnet new reactor -n MyApp' diff --git a/docs/_pipeline/templates/dev-tooling.md.dt b/docs/_pipeline/templates/dev-tooling.md.dt index e9b281c69..80b79052c 100644 --- a/docs/_pipeline/templates/dev-tooling.md.dt +++ b/docs/_pipeline/templates/dev-tooling.md.dt @@ -164,7 +164,13 @@ subcommands map one-to-one to the workflows below. | `mur upgrade` | Re-pack the framework and refresh templates + plugin after a `git pull` | `mur upgrade` | | `mur figma watch` | Poll a Figma file for design changes | `mur figma watch` | | `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; scaffolded apps default to the public Reactor preview unless `--reactor-version` is supplied | `mur pack-local` | -| `mur templates install` / `mur templates status` | Install or check the Windows App SDK `dotnet new` pack that provides `dotnet new reactor` (resolves prereleases that a bare `dotnet new install` can't reach) | `mur templates install` | +| `mur templates status` | Report whether `dotnet new reactor` resolves (exit `0` available, `1` probe failed, `2` pack too old, `3` pack missing) | `mur templates status` | + +To *install* the template pack, use the Windows App SDK CLI rather than `mur`: +`winapp new -t reactor -n MyApp` installs it on demand and scaffolds in one +step, and `winapp new --list` installs it without scaffolding. `bootstrap.ps1` +drives that same command, so a bootstrapped checkout can go straight to +`dotnet new reactor`. Beyond the subcommands there are four top-level options worth knowing: `mur --create ` scaffolds a new Reactor project, `mur --skill` diff --git a/docs/_pipeline/templates/getting-started.md.dt b/docs/_pipeline/templates/getting-started.md.dt index 2b772617c..f4807c5d6 100644 --- a/docs/_pipeline/templates/getting-started.md.dt +++ b/docs/_pipeline/templates/getting-started.md.dt @@ -46,19 +46,35 @@ state and Reactor keeps the screen in sync. ## Setup -If you just want to build an app, install the template pack and go — you do not -need to clone this repo: +If you just want to build an app, scaffold one and go — you do not need to +clone this repo: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha -dotnet new reactor -n MyApp +winget install Microsoft.WinAppCli +winapp new -t reactor -n MyApp cd MyApp dotnet run ``` +`winapp` is the Windows App SDK CLI. `winapp new` installs the template pack on +demand (and offers to update a stale one), so there is no separate install step +and no version to pin. + That gives you four starting points — `reactor` (blank), `reactor-mvu` (Model-View-Update via `UseReducer`), `reactor-navview` (`NavigationView` shell) -and `reactor-tabview` (`TabView` shell). +and `reactor-tabview` (`TabView` shell). `winapp new --list` prints them all, and +omitting `-t` picks one interactively. + +Prefer plain .NET tooling? Install the pack yourself, then scaffold with +`dotnet new`: + +```powershell +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates:: +dotnet new reactor -n MyApp +``` + +The pack is prerelease-only today, and `dotnet new install` resolves stable +versions unless you pin one explicitly — which is the step `winapp new` removes. Scaffolded apps are **packaged** (single-project MSIX), so `dotnet run` registers a loose-layout package and launches the app with full package @@ -79,7 +95,7 @@ cd microsoft-ui-reactor `bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs `mur pack-local` to produce local source-built framework snapshots, installs the -Windows App SDK template pack via `mur templates install`, +Windows App SDK template pack, and drops the Reactor agent plugin under `~/.claude/plugins/reactor` (symlink when allowed, copy otherwise). @@ -220,12 +236,12 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates:: ``` -`mur templates install` does the same thing but resolves the newest published -version first — including prereleases, which a bare `dotnet new install` cannot -reach. `mur templates status` reports whether `dotnet new reactor` is available. +The pack is prerelease-only today, so pin a version explicitly — a bare +`dotnet new install` resolves stable versions only. `winapp new -t reactor` +collapses both steps: it installs the newest pack on demand, then scaffolds. **7. (Optional) Install the Reactor agent plugin.** If you use Claude Code or another agent and want it to author Reactor code with the right @@ -275,10 +291,10 @@ the install must happen from a shell that isn't already running `mur`). The Reactor templates ship in the Windows App SDK `dotnet new` pack (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` -needs no source checkout. If it's missing, run -`dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha` — or -`mur templates install`, which additionally resolves prerelease versions that a -bare `dotnet new install` cannot reach. +needs no source checkout. If it's missing, `winapp new -t reactor` installs the +pack on demand and scaffolds in one step. To install it without scaffolding, run +`dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::` — +the pack is prerelease-only today, so the explicit version pin is required. ## Creating a Project diff --git a/docs/guide/dev-tooling.md b/docs/guide/dev-tooling.md index f9a997df2..382f7a6e3 100644 --- a/docs/guide/dev-tooling.md +++ b/docs/guide/dev-tooling.md @@ -183,7 +183,13 @@ subcommands map one-to-one to the workflows below. | `mur upgrade` | Re-pack the framework and refresh templates + plugin after a `git pull` | `mur upgrade` | | `mur figma watch` | Poll a Figma file for design changes | `mur figma watch` | | `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; scaffolded apps default to the public Reactor preview unless `--reactor-version` is supplied | `mur pack-local` | -| `mur templates install` / `mur templates status` | Install or check the Windows App SDK `dotnet new` pack that provides `dotnet new reactor` (resolves prereleases that a bare `dotnet new install` can't reach) | `mur templates install` | +| `mur templates status` | Report whether `dotnet new reactor` resolves (exit `0` available, `1` probe failed, `2` pack too old, `3` pack missing) | `mur templates status` | + +To *install* the template pack, use the Windows App SDK CLI rather than `mur`: +`winapp new -t reactor -n MyApp` installs it on demand and scaffolds in one +step, and `winapp new --list` installs it without scaffolding. `bootstrap.ps1` +drives that same command, so a bootstrapped checkout can go straight to +`dotnet new reactor`. Beyond the subcommands there are four top-level options worth knowing: `mur --create ` scaffolds a new Reactor project, `mur --skill` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index c09b19f59..df2abcd67 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -34,19 +34,35 @@ state and Reactor keeps the screen in sync. ## Setup -If you just want to build an app, install the template pack and go — you do not -need to clone this repo: +If you just want to build an app, scaffold one and go — you do not need to +clone this repo: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha -dotnet new reactor -n MyApp +winget install Microsoft.WinAppCli +winapp new -t reactor -n MyApp cd MyApp dotnet run ``` +`winapp` is the Windows App SDK CLI. `winapp new` installs the template pack on +demand (and offers to update a stale one), so there is no separate install step +and no version to pin. + That gives you four starting points — `reactor` (blank), `reactor-mvu` (Model-View-Update via `UseReducer`), `reactor-navview` (`NavigationView` shell) -and `reactor-tabview` (`TabView` shell). +and `reactor-tabview` (`TabView` shell). `winapp new --list` prints them all, and +omitting `-t` picks one interactively. + +Prefer plain .NET tooling? Install the pack yourself, then scaffold with +`dotnet new`: + +```powershell +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates:: +dotnet new reactor -n MyApp +``` + +The pack is prerelease-only today, and `dotnet new install` resolves stable +versions unless you pin one explicitly — which is the step `winapp new` removes. Scaffolded apps are **packaged** (single-project MSIX), so `dotnet run` registers a loose-layout package and launches the app with full package @@ -67,7 +83,7 @@ cd microsoft-ui-reactor `bootstrap.ps1` packs and installs `mur` as a `dotnet tool` global install (so it's on PATH cross-shell with no manual `$env:Path` edits), runs `mur pack-local` to produce local source-built framework snapshots, installs the -Windows App SDK template pack via `mur templates install`, +Windows App SDK template pack, and drops the Reactor agent plugin under `~/.claude/plugins/reactor` (symlink when allowed, copy otherwise). @@ -208,12 +224,12 @@ dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj ` App SDK template pack on NuGet.org, not from this checkout: ```powershell -dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha +dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates:: ``` -`mur templates install` does the same thing but resolves the newest published -version first — including prereleases, which a bare `dotnet new install` cannot -reach. `mur templates status` reports whether `dotnet new reactor` is available. +The pack is prerelease-only today, so pin a version explicitly — a bare +`dotnet new install` resolves stable versions only. `winapp new -t reactor` +collapses both steps: it installs the newest pack on demand, then scaffolds. **7. (Optional) Install the Reactor agent plugin.** If you use Claude Code or another agent and want it to author Reactor code with the right @@ -262,10 +278,10 @@ the install must happen from a shell that isn't already running `mur`). > **Caveat:** The Reactor templates ship in the Windows App SDK `dotnet new` pack > (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`), so `dotnet new reactor` -> needs no source checkout. If it's missing, run -> `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha` — or -> `mur templates install`, which additionally resolves prerelease versions that a -> bare `dotnet new install` cannot reach. +> needs no source checkout. If it's missing, `winapp new -t reactor` installs the +> pack on demand and scaffolds in one step. To install it without scaffolding, run +> `dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::` — +> the pack is prerelease-only today, so the explicit version pin is required. ## Creating a Project diff --git a/src/Reactor.Cli/Doctor/DoctorCommand.cs b/src/Reactor.Cli/Doctor/DoctorCommand.cs index dd329de07..f1f75eac6 100644 --- a/src/Reactor.Cli/Doctor/DoctorCommand.cs +++ b/src/Reactor.Cli/Doctor/DoctorCommand.cs @@ -151,12 +151,12 @@ public static int Run(string[] args) var ver = WinAppSdkTemplates.GetInstalledVersion() ?? "(unknown)"; Fail("dotnet new template", $"{WinAppSdkTemplates.PackageId} {ver} is installed but does not provide `dotnet new {WinAppSdkTemplates.BlankShortName}`. " + - $"Update to a version that ships the Reactor templates: `mur templates install`."); + $"Update to a version that ships the Reactor templates: `winapp new --list --template-version latest`."); failures++; } else { - Fail("dotnet new template", $"{WinAppSdkTemplates.PackageId} not registered, so `dotnet new {WinAppSdkTemplates.BlankShortName}` is unavailable. Run `./bootstrap.ps1`, `mur upgrade`, or `mur templates install`."); + Fail("dotnet new template", $"{WinAppSdkTemplates.PackageId} not registered, so `dotnet new {WinAppSdkTemplates.BlankShortName}` is unavailable. Run `./bootstrap.ps1`, or install the pack with `winapp new --list`."); failures++; } diff --git a/src/Reactor.Cli/Templates/TemplatesCommand.cs b/src/Reactor.Cli/Templates/TemplatesCommand.cs index d728c5065..96e275adc 100644 --- a/src/Reactor.Cli/Templates/TemplatesCommand.cs +++ b/src/Reactor.Cli/Templates/TemplatesCommand.cs @@ -1,25 +1,20 @@ -// `mur templates` — manage the `dotnet new` template pack that provides -// `dotnet new reactor`. +// `mur templates` — report whether the `dotnet new` template pack that provides +// `dotnet new reactor` is usable. // // Reactor's app templates ship inside the Windows App SDK template pack // (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`) rather than being built -// from this checkout. `bootstrap.ps1` §5 and `mur upgrade` both route through -// here so there is a single place that knows how to resolve and install it. +// from this checkout. // -// Subcommands: -// install Install (or reinstall) the pack. Resolves the newest published -// version — newest stable, else newest prerelease — because -// `dotnet new install` has no --prerelease switch and would -// otherwise fail while the pack is prerelease-only. -// status Report whether the pack is registered. +// This command only *reports*. Installing is the Windows App SDK CLI's job — +// `winapp new` installs the pack on demand and scaffolds in one step, and +// `bootstrap.ps1` §5 drives it through `winapp new --list`. `mur templates +// install` used to exist because `dotnet new install` has no --prerelease switch +// and resolves stable-only, which fails while the pack is prerelease-only; +// `winapp` handles that, so the installer is gone rather than duplicated. // -// Flags (install): -// --source Folder of .nupkg files, to test an unpublished build of -// the pack. Must be a local folder, not a feed URL: -// `dotnet new install` cannot be restricted to one feed -// (--add-source only adds one), so a URL source can be -// silently satisfied from nuget.org instead. -// --version Pin an explicit version instead of resolving. +// Subcommands: +// status Report whether `dotnet new reactor` resolves. Exit codes are +// load-bearing — see StatusExit. namespace Microsoft.UI.Reactor.Cli.Templates; @@ -37,10 +32,20 @@ public static int Run(string[] args) switch (sub) { - case "install": - return Install(args.Skip(1).ToArray()); case "status": return Status(); + case "install": + // Named explicitly rather than falling into "unknown subcommand": + // this command existed, `bootstrap.ps1` and the docs used to call + // it, and silently rejecting it as a typo would send someone + // looking for a misspelling instead of the replacement. + Console.Error.WriteLine( + "mur templates install has been removed. Scaffold with the Windows App SDK CLI instead:"); + Console.Error.WriteLine($" winapp new -t {WinAppSdkTemplates.BlankShortName} -n MyApp"); + Console.Error.WriteLine( + " It installs the template pack on demand. To install the pack without scaffolding:"); + Console.Error.WriteLine(" winapp new --list"); + return 1; default: Console.Error.WriteLine($"mur templates: unknown subcommand '{sub}'."); Console.Error.WriteLine(); @@ -49,96 +54,24 @@ public static int Run(string[] args) } } - static int Install(string[] args) + static void ShowHelp() { - // Help must never mutate the machine: `mur templates install --help` - // previously fell straight through to a real install. - if (args.Any(a => a is "--help" or "-h")) - { - ShowInstallHelp(); - return 0; - } - - // Reject anything we don't understand rather than silently ignoring it — - // a typo like `--sorce ./pkgs` would otherwise install from the wrong place. - if (!TryParseInstallArgs(args, out var source, out var version, out var feed, out var error)) - { - Console.Error.WriteLine($"mur templates install: {error}"); - Console.Error.WriteLine(); - ShowInstallHelp(); - return 1; - } - - Console.WriteLine($"Installing {WinAppSdkTemplates.PackageId} (`dotnet new {WinAppSdkTemplates.BlankShortName}`)"); - - // Resolve a local folder to an absolute path: `dotnet new install` runs - // with its own working directory and won't see a relative one. - if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) - source = Path.GetFullPath(source!); - - var outcome = WinAppSdkTemplates.Install(Directory.GetCurrentDirectory(), source, version, feed); - if (outcome == WinAppSdkTemplates.InstallOutcome.Failed) - { - Console.Error.WriteLine(); - Console.Error.WriteLine($"mur templates install: `dotnet new install` failed."); - Console.Error.WriteLine(" To install an unpublished build, pass a folder of nupkgs:"); - Console.Error.WriteLine(" mur templates install --source "); - Console.Error.WriteLine(" To pin an explicit version:"); - Console.Error.WriteLine(" mur templates install --version "); - Console.Error.WriteLine(" Note: `dotnet new install` does not use the NuGet credential provider, so an"); - Console.Error.WriteLine(" authenticated feed reports \"the package does not exist\". Restore the package"); - Console.Error.WriteLine(" first, then pass the cached .nupkg path to --source."); - return 1; - } - - // Don't claim an install happened when the existing pack was simply kept - // or was already current — the user needs to know whether anything moved. + Console.WriteLine("Usage: mur templates status"); Console.WriteLine(); - Console.WriteLine(DescribeOutcome(outcome)); - - // "Installed" is not "usable". KeptExisting in particular means version - // resolution failed and an older pack was left alone — and 0.0.6-alpha - // shipped without the Reactor templates, so printing scaffold commands - // here would hand the user four lines that immediately fail. Print them - // only once the short name actually resolves. - var available = WinAppSdkTemplates.AreTemplatesAvailable(); - if (available == true) - { - Console.WriteLine("Scaffold an app with:"); - foreach (var name in WinAppSdkTemplates.ShortNames) - Console.WriteLine($" dotnet new {name} -n MyApp"); - return ExitCodeForAvailability(available); - } - - Console.Error.WriteLine(); - Console.Error.WriteLine(available is null - ? $"mur templates install: could not enumerate `dotnet new` templates, so `dotnet new " + - $"{WinAppSdkTemplates.BlankShortName}` is unverified. Check with `mur templates status`." - : $"mur templates install: {WinAppSdkTemplates.PackageId} is installed but does not provide " + - $"`dotnet new {WinAppSdkTemplates.BlankShortName}` — that version predates the Reactor " + - $"templates. Pin a newer one with `mur templates install --version `."); - return TemplatesUnavailableExit; + Console.WriteLine($"Reports whether `dotnet new {WinAppSdkTemplates.BlankShortName}` resolves, i.e. whether"); + Console.WriteLine($"{WinAppSdkTemplates.PackageId} is installed and carries the Reactor templates."); + Console.WriteLine(); + Console.WriteLine("Exit codes:"); + Console.WriteLine($" {StatusExit.Available} `dotnet new {WinAppSdkTemplates.BlankShortName}` resolves"); + Console.WriteLine($" {StatusExit.ProbeFailed} the `dotnet new` template engine could not be enumerated"); + Console.WriteLine($" {StatusExit.InstalledButUnusable} the pack is installed but does not carry the Reactor templates"); + Console.WriteLine($" {StatusExit.NotInstalled} the pack is not installed"); + Console.WriteLine(); + Console.WriteLine("To install the pack, use the Windows App SDK CLI:"); + Console.WriteLine($" winapp new -t {WinAppSdkTemplates.BlankShortName} -n MyApp # installs on demand, then scaffolds"); + Console.WriteLine(" winapp new --list # installs on demand, scaffolds nothing"); } - /// - /// Exit code for "the install itself worked, but `dotnet new reactor` still - /// doesn't resolve" — distinct from 1, which means the install failed. - /// - /// - /// bootstrap.ps1 needs to tell these apart. A genuine install failure is fatal - /// there, but an old-but-installed pack has its own warning path and next-step - /// guidance; collapsing both onto 1 makes that path unreachable. - /// - internal const int TemplatesUnavailableExit = 2; - - /// - /// Exit code for an install that ran, given the post-install availability probe - /// (null = could not enumerate). Pure, so the contract bootstrap.ps1 - /// depends on is testable without touching the machine. - /// - internal static int ExitCodeForAvailability(bool? available) => - available == true ? 0 : TemplatesUnavailableExit; - /// /// `mur templates status` exit codes. Three distinct situations that all mean /// "cannot scaffold" but call for different remediation, so callers (bootstrap @@ -193,11 +126,13 @@ static int Status() case StatusExit.InstalledButUnusable: Console.WriteLine( $"{WinAppSdkTemplates.PackageId} {version ?? "(unknown)"} is installed, but it does not provide " + - $"`dotnet new {WinAppSdkTemplates.BlankShortName}`. Update it with `mur templates install`."); + $"`dotnet new {WinAppSdkTemplates.BlankShortName}`. Update it with `winapp new --list`."); break; case StatusExit.NotInstalled: - Console.WriteLine($"{WinAppSdkTemplates.PackageId} is NOT installed. Run `mur templates install`."); + Console.WriteLine( + $"{WinAppSdkTemplates.PackageId} is NOT installed. Install it with `winapp new --list`, " + + $"or scaffold directly with `winapp new -t {WinAppSdkTemplates.BlankShortName} -n MyApp`."); break; default: @@ -207,97 +142,4 @@ static int Status() return exitCode; } - - /// - /// Human-readable summary of what an install actually did. Split out (and - /// internal) so the mapping is testable: the bug this guards is reporting - /// "Installed." when the command deliberately kept an existing pack. - /// - internal static string DescribeOutcome(WinAppSdkTemplates.InstallOutcome outcome) => outcome switch - { - WinAppSdkTemplates.InstallOutcome.KeptExisting => - "Kept the existing install (could not resolve a published version).", - WinAppSdkTemplates.InstallOutcome.AlreadyCurrent => "Already up to date.", - WinAppSdkTemplates.InstallOutcome.Updated => "Updated.", - WinAppSdkTemplates.InstallOutcome.Installed => "Installed.", - _ => "Install failed.", - }; - - static void ShowHelp() - { - Console.WriteLine("Usage: mur templates [options]"); - Console.WriteLine(); - Console.WriteLine($"Manages {WinAppSdkTemplates.PackageId}, the Windows App SDK"); - Console.WriteLine($"`dotnet new` pack that provides `dotnet new {WinAppSdkTemplates.BlankShortName}` and friends."); - Console.WriteLine(); - Console.WriteLine("Subcommands:"); - Console.WriteLine(" install Install or reinstall the template pack"); - Console.WriteLine(" status Report whether the pack is registered"); - Console.WriteLine(); - Console.WriteLine("Run `mur templates install --help` for install options."); - } - - static void ShowInstallHelp() - { - Console.WriteLine("Usage: mur templates install [--source ] [--version ] [--feed ]"); - Console.WriteLine(); - Console.WriteLine($"Installs {WinAppSdkTemplates.PackageId}. With no options it resolves the"); - Console.WriteLine("newest published version (newest stable, else newest prerelease)."); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --source Folder of .nupkg files, to install an unpublished build."); - Console.WriteLine(" Must be a local folder — feed URLs are rejected, because"); - Console.WriteLine(" `dotnet new install` cannot be restricted to one feed and"); - Console.WriteLine(" would silently accept the package from another. Restore"); - Console.WriteLine(" the package first, then point at the cache folder."); - Console.WriteLine(" --version Pin an explicit version instead of resolving."); - Console.WriteLine(" --feed NuGet v3 service index to resolve the version from, for"); - Console.WriteLine(" machines that reach a mirror but not nuget.org. Used only"); - Console.WriteLine(" for version lookup; falls back to nuget.org."); - Console.WriteLine(" --help, -h Show this help."); - Console.WriteLine(); - Console.WriteLine("Exit codes:"); - Console.WriteLine(" 0 installed (or already current) and `dotnet new reactor` resolves"); - Console.WriteLine(" 1 the install failed, or the arguments were rejected"); - Console.WriteLine(" 2 the pack is installed but `dotnet new reactor` does not resolve"); - } - - /// - /// Strict argv parsing for `install`. Rejects unknown flags, bare positional - /// arguments, and flags with a missing value, so a typo cannot silently change - /// what gets installed. - /// - static bool TryParseInstallArgs(string[] args, out string? source, out string? version, out string? feed, out string? error) - { - source = null; - version = null; - feed = null; - error = null; - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - switch (arg) - { - case "--source": - case "--version": - case "--feed": - if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) - { - error = $"'{arg}' requires a value."; - return false; - } - if (arg == "--source") source = args[++i]; - else if (arg == "--feed") feed = args[++i]; - else version = args[++i]; - break; - default: - error = arg.StartsWith("-", StringComparison.Ordinal) - ? $"unknown option '{arg}'." - : $"unexpected argument '{arg}'."; - return false; - } - } - return true; - } } diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 1cc2b2cbb..55a09ebf5 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -3,8 +3,7 @@ // // Reactor used to ship its own `Microsoft.UI.Reactor.ProjectTemplates` pack // (`dotnet new reactorapp`, unpackaged). The Windows App SDK template pack now -// carries first-class Reactor templates alongside the WinUI 3 XAML ones, so -// that's what `bootstrap.ps1` installs and what `mur doctor` looks for. The +// carries first-class Reactor templates alongside the WinUI 3 XAML ones. The // legacy pack's source has been deleted from this repo and it is no longer // built or published — the versions already on NuGet.org are all that remain, // and they are deprecated. @@ -15,29 +14,22 @@ // • scaffolded apps are **packaged** (single-project MSIX) rather than // unpackaged, so `dotnet run` launches them with package identity. // -// Why this resolves a version instead of just installing the bare package id: -// `dotnet new install ` has no `--prerelease` switch and resolves -// stable-only, so it fails outright while the pack is publishing prereleases. -// We query the NuGet flat-container index ourselves and install an explicit -// `::`, preferring the newest stable and falling back to the newest -// prerelease — so a fresh clone works against whatever is published today. -// -// Why `--force` is used sparingly: `dotnet new install --force` uninstalls the -// existing package *before* downloading the replacement, so a failed install -// leaves the machine with no templates at all. (Observed: `--force` with a bare -// package id that has no stable version uninstalled the working prerelease and -// then failed with "the package does not exist".) We therefore only pass -// `--force` when replacing an install with a version we already know exists. +// This type only *probes* — it answers "is the pack installed?" and "will +// `dotnet new reactor` resolve?" for `mur doctor` and `mur templates status`. +// Installing is the Windows App SDK CLI's job: `winapp new` installs the pack +// on demand and scaffolds in one step. Reactor used to carry its own installer +// (`mur templates install`) because `dotnet new install` has no `--prerelease` +// switch and resolves stable-only, which fails while the pack is prerelease-only; +// working around that meant resolving versions off the NuGet flat container and +// tiptoeing around `dotnet new install --force`, which uninstalls the existing +// package *before* downloading the replacement. `winapp` handles all of it, so +// that machinery is gone. using System.ComponentModel; using System.Diagnostics; -using System.Net.Http; -using System.Text.Json; using System.Text.RegularExpressions; -using Microsoft.UI.Reactor.Cli.Pack; namespace Microsoft.UI.Reactor.Cli.Templates; - public static class WinAppSdkTemplates { /// NuGet id of the template pack that ships the Reactor templates. @@ -65,59 +57,6 @@ public static class WinAppSdkTemplates "reactor-tabview", ]; - // Lists every published version (including prereleases) as a JSON string array. - const string FlatContainerIndexUrl = - "https://api.nuget.org/v3-flatcontainer/microsoft.windowsappsdk.winui.csharp.templates/index.json"; - - /// - /// The `PackageBaseAddress/3.0.0` resource (the flat container) advertised by a - /// NuGet v3 service index, or null when the document doesn't declare one. - /// - /// - /// A service index host generally has no `/flatcontainer/` path of its own, so - /// guessing one returns 404 for every package — including ones that certainly - /// exist. The base address has to be read out of the index. - /// - internal static string? ParsePackageBaseAddress(string serviceIndexJson) - { - // The version suffix has moved across service-index revisions (3.0.0, - // 3.0.0-beta), so match the family rather than one literal. - static string? BaseAddressOf(JsonElement resource) - { - if (!resource.TryGetProperty("@type", out var type) || type.ValueKind != JsonValueKind.String) - return null; - if (!(type.GetString() ?? string.Empty) - .StartsWith("PackageBaseAddress", StringComparison.OrdinalIgnoreCase)) - return null; - if (!resource.TryGetProperty("@id", out var id) || id.ValueKind != JsonValueKind.String) - return null; - return id.GetString(); - } - - try - { - using var doc = JsonDocument.Parse(serviceIndexJson); - if (doc.RootElement.ValueKind != JsonValueKind.Object || - !doc.RootElement.TryGetProperty("resources", out var resources) || - resources.ValueKind != JsonValueKind.Array) - return null; - - var value = resources.EnumerateArray() - .Where(resource => resource.ValueKind == JsonValueKind.Object) - .Select(BaseAddressOf) - .FirstOrDefault(address => !string.IsNullOrWhiteSpace(address)); - - if (value is null) return null; - return value.EndsWith('/') ? value : value + "/"; - } - catch (JsonException) - { - // Not a service index — fall back to the public flat container. - return null; - } - } - - /// /// True when the template *package* is registered with the `dotnet new` /// engine. Returns null when the installed-package list could not be /// enumerated at all (no `dotnet` on PATH, engine error) so callers can @@ -252,642 +191,6 @@ internal static bool InterpretTemplateListOutput(string output) return null; } - /// What an call actually did. - public enum InstallOutcome - { - /// The pack was not present and is now installed. - Installed, - /// An older pack was replaced with a newer one. - Updated, - /// The resolved version was already installed; nothing changed. - AlreadyCurrent, - /// No version could be resolved, so the existing install was left untouched. - KeptExisting, - /// The install was attempted and failed. - Failed, - } - - /// What an call should do, decided from inputs alone. - /// - /// Split out from so the decision table is unit-testable - /// without shelling out to the template engine or touching the machine. The - /// destructive case is : it is the only - /// path that passes `--force`, which uninstalls before downloading. - /// - internal enum InstallAction - { - /// Leave the existing install alone (nothing resolvable to move to). - KeepExisting, - /// Install without `--force` — nothing is installed, so there is nothing to lose. - PlainInstall, - /// Replace an existing install with a version known to exist. - ForcedReplace, - /// The resolved target is already installed; do nothing. - AlreadyCurrent, - /// - /// An explicit version was pinned but could not be confirmed to exist. Refuse - /// rather than `--force`, which would uninstall the working pack and then fail. - /// - RefuseUnverifiedPin, - } - - /// - /// Pure decision table for . - /// - /// Currently installed version, or null. - /// Version we want, or null when none could be resolved. - /// - /// True only when was confirmed present in the feed or - /// folder. A pinned version that could not be confirmed must never be forced. - /// - /// True when an extra NuGet source was supplied. - internal static InstallAction PlanInstall(string? installed, string? target, bool targetExists, bool hasSource) - { - // An explicit source says "install the build that is *here*". But - // `dotnet new install` has no feed-isolation switch — `--add-source` only - // *adds* to the configured feeds — so anything we cannot confirm in that - // source gets resolved from nuget.org instead. That is a different package, - // frequently under the very same version string (a locally packed - // 0.0.7-alpha vs the published 0.0.7-alpha), so neither the id nor the pin - // would reveal the substitution. Sources are local folders by the time we - // get here (URL sources are rejected up front), so the listing is - // authoritative and absence is definitive: refuse rather than install - // something the caller did not point at. - if (hasSource && (target is null || !targetExists)) - return InstallAction.RefuseUnverifiedPin; - - if (target is null) - return installed is not null ? InstallAction.KeepExisting : InstallAction.PlainInstall; - - // An explicit source means "get it from here even if the id/version matches", - // so don't short-circuit on an equal version string in that case. - if (installed is not null && !hasSource && - string.Equals(installed, target, StringComparison.OrdinalIgnoreCase)) - return InstallAction.AlreadyCurrent; - - // Nothing installed: a plain install cannot destroy anything. - if (installed is null) - return InstallAction.PlainInstall; - - // Replacing an existing install requires --force, which uninstalls first. - // Only take that path for a version we know is actually there. - return targetExists ? InstallAction.ForcedReplace : InstallAction.RefuseUnverifiedPin; - } - - /// - /// Masks credentials in a NuGet source before it is echoed. Feed URLs can carry a - /// PAT in the user-info or query segment, and this command line is printed to the - /// console and into CI logs. - /// - internal static string RedactSource(string source) - { - if (string.IsNullOrWhiteSpace(source)) return source; - if (!Uri.TryCreate(source, UriKind.Absolute, out var uri)) - { - // Unparsable. A plain local path has nothing to hide, but a malformed - // URL-ish value can still carry a PAT — and `Uri` is stricter than it - // looks: `file://user:pat@host/share` throws outright, so the - // credential-bearing cases land *here* rather than in the branches - // below. Mask conservatively when it looks like a URL. - return source.Contains("://", StringComparison.Ordinal) - ? RedactUnparsableUrl(source) - : source; - } - - // A local *path* has nothing secret in it, but `file://user:pat@host/share` - // is also IsFile — returning it unchanged would print the credential. Only - // short-circuit when there is demonstrably nothing to mask. - if (uri.IsFile && - string.IsNullOrEmpty(uri.UserInfo) && - string.IsNullOrEmpty(uri.Query) && - string.IsNullOrEmpty(uri.Fragment)) - return source; - - var builder = new UriBuilder(uri) - { - UserName = string.IsNullOrEmpty(uri.UserInfo) ? string.Empty : "***", - Password = string.Empty, - Query = string.IsNullOrEmpty(uri.Query) ? string.Empty : "***", - Fragment = string.IsNullOrEmpty(uri.Fragment) ? string.Empty : "***", - }; - return builder.Uri.ToString(); - } - - /// - /// Best-effort masking for a URL-looking value that refused to - /// parse: drops anything from the first ? or #, and replaces the - /// authority's user-info with ***. - /// - /// - /// Deliberately string-based. The input is by definition not a valid URI, so - /// there is no parser to lean on — and the alternative (echoing it verbatim) - /// is what leaks the token. - /// - static string RedactUnparsableUrl(string source) - { - var cut = source.IndexOfAny(new[] { '?', '#' }); - var head = cut >= 0 ? source[..cut] + "***" : source; - - var schemeEnd = head.IndexOf("://", StringComparison.Ordinal); - if (schemeEnd < 0) return head; - - var authorityStart = schemeEnd + 3; - var authorityEnd = head.IndexOf('/', authorityStart); - var authority = authorityEnd < 0 ? head[authorityStart..] : head[authorityStart..authorityEnd]; - - // Last '@' wins: a password may itself contain one. - var at = authority.LastIndexOf('@'); - if (at < 0) return head; - - var tail = authorityEnd < 0 ? string.Empty : head[authorityEnd..]; - return head[..authorityStart] + "***" + authority[at..] + tail; - } - - /// - /// Installs (or updates) the template pack. - /// - /// Working directory for the `dotnet` process. - /// - /// A local folder holding the nupkg, passed to `dotnet new install --add-source`. - /// Feed URLs are rejected: `dotnet new install` has no feed-isolation switch - /// (`--add-source` only adds to the configured sources), so a URL source can be - /// silently satisfied from another feed — often under the very same version string. - /// - /// Explicit version to pin. When omitted the newest published version is resolved. - /// - /// NuGet v3 service-index URL for the mirror this clone is configured against. - /// Used for version resolution *and* added to the install as a supplementary - /// source: `dotnet new install` runs its own restore and ignores the MSBuild - /// restore environment, so a mirror-only machine would otherwise resolve a - /// version and then fail to download it. Ignored unless it passes - /// , which also forbids credentials — so nothing - /// secret reaches the child process command line. - /// - /// - /// Returns what actually happened rather than a bare exit code: "kept the - /// existing install because nothing could be resolved" is a success for - /// exit-code purposes but must not be reported to the user as "installed". - /// - public static InstallOutcome Install(string workingDirectory, string? source = null, string? version = null, string? feed = null) - { - var hasSource = !string.IsNullOrWhiteSpace(source); - var pinned = !string.IsNullOrWhiteSpace(version); - // Only a policy-passing feed may be handed to `dotnet new install`, and - // only when there is no local `--source`. Adding the mirror alongside a - // folder would reintroduce the collision this command exists to prevent: - // NuGet treats identical id+version candidates across sources as - // interchangeable, so an unpublished local 0.0.7-alpha could be served by - // the published 0.0.7-alpha instead. With a folder source the mirror is - // unused for resolution too — the folder listing is authoritative. - var installFeed = !hasSource && !string.IsNullOrWhiteSpace(feed) && IsAllowedFeedUrl(feed) ? feed : null; - // `--source` must be a local folder of nupkgs. - // - // `dotnet new install` has no feed-isolation switch: `--add-source` only - // *augments* the configured sources. So `::` passed alongside - // a feed URL can be satisfied from nuget.org instead, silently installing a - // different package than the one asked for. That is not theoretical — an - // unpublished build and the published pack routinely carry the *same* - // version string (a local `0.0.7-alpha` vs the published `0.0.7-alpha`), so - // the version pin cannot disambiguate them either. - // - // A folder has neither problem: it is enumerable, so a pin is confirmable, - // and `--add-source ` plus an exact version resolves to the file - // that is actually there. `dotnet new install` also ignores the NuGet - // credential provider, so an authenticated feed URL fails anyway with a - // misleading "the package does not exist". Restore first, then point at the - // cache folder. - if (hasSource && !Directory.Exists(source)) - { - // Redact before echoing: a feed URL can carry a PAT, and this line lands - // in console output and CI logs. - Console.Error.WriteLine( - $" error: --source '{RedactSource(source!)}' is not a local folder. `dotnet new install` " + - "cannot be restricted to a single feed (--add-source only adds one), so a feed URL can " + - "silently resolve the package from somewhere else. Restore the package first, then pass " + - "the folder holding the .nupkg:"); - Console.Error.WriteLine( - $" mur templates install --source %USERPROFILE%\\.nuget\\packages\\{PackageId.ToLowerInvariant()}\\"); - return InstallOutcome.Failed; - } - - var installed = GetInstalledVersion(); - - string? target; - bool targetExists; - if (pinned) - { - target = version!.Trim(); - // Confirm the pin before considering --force. Null means "couldn't tell", - // which is treated as unverified — never destructive on a maybe. With a - // source, the source is a local folder (URL sources are rejected above), - // so its listing is authoritative: absent really means absent. - var available = ResolveAvailableVersions(source, feed); - targetExists = available is not null && - available.Any(v => string.Equals(v, target, StringComparison.OrdinalIgnoreCase)); - } - else - { - target = ResolveLatestVersion(source, feed); - // A resolved target came out of the feed listing, so it exists by construction. - targetExists = target is not null; - } - - switch (PlanInstall(installed, target, targetExists, hasSource)) - { - case InstallAction.KeepExisting: - Console.Error.WriteLine( - $" warning: could not resolve a published version of {PackageId}; " + - $"keeping the installed {installed}. Re-run with network access, or pass an explicit version."); - return InstallOutcome.KeptExisting; - - case InstallAction.AlreadyCurrent: - Console.WriteLine($" Already installed: {PackageId} {installed}"); - return InstallOutcome.AlreadyCurrent; - - case InstallAction.RefuseUnverifiedPin: - Console.Error.WriteLine(hasSource - ? (target is null - ? $" error: no {PackageId} .nupkg found in --source '{RedactSource(source!)}'. " + - $"Installing anyway would resolve the package from another configured feed, " + - $"because `--add-source` only adds to the configured sources. Point --source at " + - $"a folder that has it." - : $" error: {PackageId} {target} is not in --source '{RedactSource(source!)}'. " + - $"Installing anyway would let `--add-source` resolve that version from another " + - $"configured feed — a different package under the same version string. Check the " + - $"version, or point --source at the folder that has it.") - : $" error: {PackageId} {target} could not be found in the configured sources, and " + - $"replacing an install requires `--force`, which uninstalls the current {installed} " + - $"before downloading. Refusing, so your working install survives. " + - $"Check the version, or pass --source with the folder that has it."); - return InstallOutcome.Failed; - - case InstallAction.PlainInstall: - return RunInstall(workingDirectory, target, source, installFeed, force: false, installed); - - default: // ForcedReplace - return RunInstall(workingDirectory, target, source, installFeed, force: true, installed); - } - } - - /// - /// Path to 's nupkg inside , - /// or null when it isn't there. - /// - /// - /// Installing this path is what actually makes --source authoritative. - /// `dotnet new install id::version --add-source folder` leaves every configured - /// feed active and NuGet queries them in parallel, so a local unpublished nupkg - /// that reuses a published id+version can be silently replaced by the public - /// one — enumerating the folder confirms the version exists, not which bytes - /// get selected. A file path has no such ambiguity. - /// - internal static string? FindLocalPackage(string folder, string version) - { - if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return null; - var expected = $"{PackageId}.{version}.nupkg"; - return Directory - .EnumerateFiles(folder, $"{PackageId}.*.nupkg") - .FirstOrDefault(f => string.Equals(Path.GetFileName(f), expected, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Argument list for `dotnet new install`. Pure, so the source wiring is - /// testable without launching a process. - /// - /// - /// Prefers : installing the file itself is - /// the only way a local --source is genuinely authoritative (see - /// ). Otherwise the spec is - /// <id>::<version> — `dotnet new install` has no - /// --prerelease switch, and a bare id resolves stable-only. - /// is the configured mirror, and only ever set when - /// there is no local source: `dotnet new install` runs its own restore and - /// ignores the MSBuild restore environment, so a mirror-only machine would - /// otherwise resolve a version and then fail to download it. - /// - internal static IReadOnlyList BuildInstallArgs( - string? target, string? source, string? feed, bool force, string? localPackagePath = null) - { - var args = new List { "new", "install" }; - - if (!string.IsNullOrWhiteSpace(localPackagePath)) - { - args.Add(localPackagePath!); - if (force) args.Add("--force"); - // No --add-source: the file is the package. - return args; - } - - args.Add(target is null ? PackageId : $"{PackageId}::{target}"); - if (force) args.Add("--force"); - if (!string.IsNullOrWhiteSpace(source)) - { - args.Add("--add-source"); - args.Add(source!); - } - if (!string.IsNullOrWhiteSpace(feed)) - { - args.Add("--add-source"); - args.Add(feed!); - } - return args; - } - - static InstallOutcome RunInstall(string workingDirectory, string? target, string? source, string? feed, bool force, string? installed) - { - Console.WriteLine(installed is null - ? $" Installing {PackageId} {target ?? "(latest stable)"}" - : $" Updating {PackageId} {installed} → {target}"); - - // With a local folder and a known version, install the file itself so the - // configured feeds cannot substitute a same-versioned public package. - var localPackagePath = source is not null && target is not null - ? FindLocalPackage(source, target) - : null; - - var args = BuildInstallArgs(target, source, feed, force, localPackagePath).ToList(); - - // Echo with every source redacted — redact by *position* (the value after - // each --add-source) rather than by comparing against one variable, so a - // second source can't slip through unredacted. - var echo = args.Select((a, i) => - i > 0 && string.Equals(args[i - 1], "--add-source", StringComparison.Ordinal) - ? RedactSource(a) - : a); - Console.WriteLine($" dotnet {string.Join(' ', echo)}"); - - var rc = Run(workingDirectory, args.ToArray()); - if (rc != 0) - { - if (force && installed is not null) - { - Console.Error.WriteLine( - $" warning: the update failed and `dotnet new install --force` removes the old package first, " + - $"so {PackageId} may no longer be installed. Restore it with: " + - $"dotnet new install {PackageId}::{installed}"); - } - Console.Error.WriteLine( - " note: `dotnet new install` does not use the NuGet credential provider, so an authenticated " + - "feed reports \"the package does not exist\". Restore the package first, then pass the cached " + - ".nupkg folder to --source."); - return InstallOutcome.Failed; - } - return installed is null ? InstallOutcome.Installed : InstallOutcome.Updated; - } - - /// - /// Whether a NuGet service-index URL is safe to query: HTTPS (or loopback - /// HTTP), with no credentials in the user-info, query string, or fragment. - /// - /// - /// Mirrors Test-ReactorPackageFeedUrl in tools/BootstrapFeedResolver.ps1, - /// which applies the same policy to -NuGetSource. Version metadata is - /// what picks the package to install, so fetching it over plaintext lets a - /// network attacker choose the version; and a credential in the URL would be - /// sent to whatever endpoint the URL names. - /// - internal static bool IsAllowedFeedUrl(string? feed) - { - if (string.IsNullOrWhiteSpace(feed)) return false; - if (!Uri.TryCreate(feed, UriKind.Absolute, out var uri)) return false; - - if (!string.IsNullOrEmpty(uri.UserInfo) || - !string.IsNullOrEmpty(uri.Query) || - !string.IsNullOrEmpty(uri.Fragment)) - return false; - - if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - return true; - - // Plaintext only where it cannot leave the machine. - return string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && - uri.IsLoopback; - } - - /// - /// Every version the configured source offers, or null when the listing could - /// not be obtained (offline, unreachable feed). Null means "couldn't tell" and - /// must never be read as "the version is absent". - /// - /// Local folder of nupkgs, read instead of any feed. - /// - /// NuGet v3 service-index URL to enumerate from. Supplied by bootstrap when the - /// clone is configured against a mirror, so a machine that cannot reach - /// nuget.org still resolves a version instead of falling back to a bare package - /// id that cannot reach this prerelease-only pack. - /// - internal static IReadOnlyList? ResolveAvailableVersions(string? source = null, string? feed = null) - { - // A local folder source is the unpublished-build test path: read the - // versions straight off the nupkg filenames rather than hitting NuGet. - if (!string.IsNullOrWhiteSpace(source) && Directory.Exists(source)) - return EnumerateLocalVersions(source!); - - using var http = CreateFeedHttpClient(); - - if (!string.IsNullOrWhiteSpace(feed)) - { - if (!IsAllowedFeedUrl(feed)) - { - Console.Error.WriteLine( - $" warning: ignoring --feed '{RedactSource(feed!)}' — a version feed must be an HTTPS URL " + - $"(or loopback HTTP) with no credentials in its user-info, query string or fragment."); - } - else - { - var versions = TryEnumerateFromFeed(http, feed!); - if (versions is not null) return versions; - Console.Error.WriteLine( - $" warning: could not enumerate {PackageId} from '{RedactSource(feed!)}'; " + - $"falling back to nuget.org."); - } - } - - return TryGetVersions(http, FlatContainerIndexUrl); - } - - /// - /// An that does not follow redirects on its own. - /// - /// - /// Automatic redirects would defeat : a validated - /// HTTPS URL can redirect to plaintext HTTP, or to a host carrying credentials in - /// the URL, and the body would be accepted without the policy ever seeing that - /// address. follows them itself and re-applies - /// the policy at every hop. - /// - static HttpClient CreateFeedHttpClient() => - new(new HttpClientHandler { AllowAutoRedirect = false }) - { - Timeout = TimeSpan.FromSeconds(15), - }; - - /// - /// GETs , re-applying to every - /// redirect hop. Returns null when the policy rejects a hop, the chain is too long, - /// or the request fails. - /// - internal static string? GetStringPolicyChecked(HttpClient http, string url) - { - // Enough for the CDN/vanity-host hops real feeds use, few enough to stop a loop. - const int MaxHops = 5; - - var current = url; - for (var hop = 0; hop < MaxHops; hop++) - { - if (!IsAllowedFeedUrl(current)) - { - Console.Error.WriteLine( - $" warning: refusing to follow '{RedactSource(current)}' — a version feed must be an " + - $"HTTPS URL (or loopback HTTP) with no credentials in its user-info, query string or fragment."); - return null; - } - - using var response = http.GetAsync(current).GetAwaiter().GetResult(); - if ((int)response.StatusCode is >= 300 and < 400) - { - var location = response.Headers.Location; - if (location is null) return null; - // A relative Location is resolved against the hop it came from, then - // re-checked at the top of the next iteration. - current = location.IsAbsoluteUri - ? location.AbsoluteUri - : new Uri(new Uri(current), location).AbsoluteUri; - continue; - } - - response.EnsureSuccessStatusCode(); - return response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - } - - Console.Error.WriteLine($" warning: too many redirects following '{RedactSource(url)}'."); - return null; - } - - /// - /// Resolves a service index to its flat container and lists the pack's versions - /// there. Null on any failure, so the caller can fall back. - /// - static IReadOnlyList? TryEnumerateFromFeed(HttpClient http, string serviceIndexUrl) - { - string? indexJson; - try - { - indexJson = GetStringPolicyChecked(http, serviceIndexUrl); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException or InvalidOperationException) - { - return null; - } - if (indexJson is null) return null; - - var baseAddress = ParsePackageBaseAddress(indexJson); - if (baseAddress is null) return null; - - // The index is fetched from a validated URL, but what it *advertises* is - // not covered by that check: a compromised or misconfigured index can point - // PackageBaseAddress at plaintext HTTP, or at a URL carrying credentials. - // Re-apply the policy before following it. - if (!IsAllowedFeedUrl(baseAddress)) - { - Console.Error.WriteLine( - $" warning: ignoring the package base address advertised by '{RedactSource(serviceIndexUrl)}' " + - $"— it must be an HTTPS URL (or loopback HTTP) with no credentials in its user-info, " + - $"query string or fragment."); - return null; - } - - // Flat-container paths are lowercase. - return TryGetVersions(http, $"{baseAddress}{PackageId.ToLowerInvariant()}/index.json"); - } - - static IReadOnlyList? TryGetVersions(HttpClient http, string flatContainerIndexUrl) - { - try - { - var json = GetStringPolicyChecked(http, flatContainerIndexUrl); - return json is null ? null : PackLocalCommand.ParseFlatContainerVersions(json); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException or InvalidOperationException) - { - Console.Error.WriteLine( - $" warning: could not query NuGet for {PackageId} versions " + - $"({ex.GetType().Name}: {ex.Message})."); - return null; - } - } - - /// - /// Newest published version of the pack — the newest stable when one exists, - /// otherwise the newest prerelease. Returns null when nothing could be - /// resolved (offline, unreachable feed, empty folder). - /// - public static string? ResolveLatestVersion(string? source = null, string? feed = null) - { - var versions = ResolveAvailableVersions(source, feed); - return versions is null ? null : SelectPreferStable(versions); - } - - /// - /// Highest stable version, or the highest prerelease when no stable exists. - /// Split out (and internal) so the preference rule is unit-testable without - /// a network round-trip. - /// - internal static string? SelectPreferStable(IEnumerable versions) - { - var all = versions as IReadOnlyList ?? versions.ToList(); - // A '-' after the core triple marks a SemVer prerelease (1.2.3-alpha). - var stable = all.Where(v => !string.IsNullOrWhiteSpace(v) && !v.Contains('-')).ToList(); - return PackLocalCommand.SelectLatestVersion(stable.Count > 0 ? stable : all); - } - - // "..nupkg" → "", case-insensitively. - internal static IReadOnlyList EnumerateLocalVersions(string folder) - { - var prefix = PackageId + "."; - var versions = new List(); - try - { - versions.AddRange(Directory - .EnumerateFiles(folder, $"{PackageId}.*.nupkg") - .Select(Path.GetFileNameWithoutExtension) - .Where(name => name is not null && - name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - .Select(name => name![prefix.Length..])); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) - { - Console.Error.WriteLine($" warning: could not enumerate '{folder}' ({ex.GetType().Name}: {ex.Message})."); - } - return versions; - } - - static int Run(string workingDirectory, params string[] arguments) - { - var psi = new ProcessStartInfo("dotnet") - { - UseShellExecute = false, - WorkingDirectory = workingDirectory, - }; - foreach (var a in arguments) psi.ArgumentList.Add(a); - - try - { - using var proc = Process.Start(psi); - if (proc is null) return 1; - proc.WaitForExit(); - return proc.ExitCode; - } - catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) - { - // Redact here too — this path formats the same argument list that the - // normal echo redacts, and `--add-source` may carry a feed URL. - Console.Error.WriteLine( - $" failed to run `dotnet {string.Join(' ', arguments.Select(RedactSource))}`: {ex.Message}"); - return 1; - } - } - static string? RunCapture(params string[] arguments) { var psi = new ProcessStartInfo("dotnet") diff --git a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs index 9f9678e20..8fbf8f419 100644 --- a/src/Reactor.Cli/Upgrade/UpgradeCommand.cs +++ b/src/Reactor.Cli/Upgrade/UpgradeCommand.cs @@ -46,58 +46,30 @@ public static int Run(string[] args) return rc; } - // 2. Make sure the `dotnet new reactor` templates are available. They ship - // in the Windows App SDK template pack rather than being built from this - // checkout, so `git pull` never invalidates them — this is a self-healing - // install-if-missing, not a reinstall. Best-effort: a developer who - // scaffolds by hand shouldn't have `mur upgrade` fail on a NuGet hiccup. + // 2. Report on the `dotnet new reactor` templates. They ship in the Windows + // App SDK template pack rather than being built from this checkout, so + // `git pull` never invalidates them and there is nothing to refresh here. + // Installing them is `winapp`'s job, so this only probes and points. Console.WriteLine(); - var templateSource = ParseFlag(args, "--templates-source", out var sourceMissingValue); - var templateVersion = ParseFlag(args, "--templates-version", out var versionMissingValue); - var templateFeed = ParseFlag(args, "--templates-feed", out var feedMissingValue); - if (sourceMissingValue || versionMissingValue || feedMissingValue) - { - var flag = sourceMissingValue ? "--templates-source" - : versionMissingValue ? "--templates-version" - : "--templates-feed"; - Console.Error.WriteLine($"mur upgrade: '{flag}' requires a value."); - return 1; - } Console.WriteLine($"==> Checking `dotnet new {WinAppSdkTemplates.BlankShortName}` templates ({WinAppSdkTemplates.PackageId})"); - // Resolve a relative folder against the caller's CWD before handing it on. - // Install() runs `dotnet new install --add-source` with repoRoot as the - // working directory, so an unqualified path would be resolved there - // instead — pointing at a different folder, or none. TemplatesCommand and - // bootstrap.ps1 already normalize for the same reason. - if (!string.IsNullOrWhiteSpace(templateSource) && Directory.Exists(templateSource)) - templateSource = Path.GetFullPath(templateSource!); - // Install() is a no-op when the resolved version is already installed, and - // deliberately leaves an existing install alone when it can't resolve a - // newer one — so this is safe to run on every upgrade. - var templateOutcome = WinAppSdkTemplates.Install(repoRoot, templateSource, templateVersion, templateFeed); - if (templateOutcome == WinAppSdkTemplates.InstallOutcome.Failed) - { - // Best-effort when it's the routine refresh — a NuGet hiccup shouldn't fail - // the whole upgrade. But if the user explicitly asked for a specific source - // or version, silently returning 0 would report success for work not done. - if (templateSource is not null || templateVersion is not null || templateFeed is not null) - { - Console.Error.WriteLine($"mur upgrade: could not install {WinAppSdkTemplates.PackageId} as requested."); - return 1; - } - Console.Error.WriteLine($" Could not install {WinAppSdkTemplates.PackageId}; the rest of the upgrade completed."); - } - else if (WinAppSdkTemplates.AreTemplatesAvailable() == false) + switch (WinAppSdkTemplates.AreTemplatesAvailable()) { - // A successful install is not a usable one: an older pack (0.0.6-alpha - // shipped before the Reactor templates existed) installs cleanly and - // still leaves `dotnet new reactor` unresolvable. Reporting "upgrade - // complete" there hands the user a scaffold command that fails. - Console.Error.WriteLine( - $" {WinAppSdkTemplates.PackageId} is installed but does not provide " + - $"`dotnet new {WinAppSdkTemplates.BlankShortName}` — that version predates the Reactor " + - $"templates. Pin a newer one with `mur templates install --version `."); - if (templateSource is not null || templateVersion is not null || templateFeed is not null) return 1; + case true: + Console.WriteLine( + $" `dotnet new {WinAppSdkTemplates.BlankShortName}` is available " + + $"({WinAppSdkTemplates.PackageId} {WinAppSdkTemplates.GetInstalledVersion() ?? "(unknown)"})."); + break; + case false: + // Best-effort: a developer who scaffolds by hand shouldn't have + // `mur upgrade` fail over a template pack it no longer manages. + Console.Error.WriteLine( + $" `dotnet new {WinAppSdkTemplates.BlankShortName}` is not available. Install the pack with " + + $"`winapp new --list`, or scaffold directly with " + + $"`winapp new -t {WinAppSdkTemplates.BlankShortName} -n MyApp`."); + break; + default: + Console.Error.WriteLine(" Could not enumerate `dotnet new` templates; skipping the check."); + break; } // 3. Refresh Claude plugin (best-effort; not every user has Claude Code). @@ -314,28 +286,6 @@ static void TryReinstallVsExtension(string repoRoot) return null; } - /// - /// Value of in , or null when - /// absent. Sets when the flag is present as - /// the final argument: scanning to args.Length - 1 would otherwise - /// silently ignore it and run the unpinned path, reporting success for work - /// the caller did not ask for. - /// - static string? ParseFlag(string[] args, string name, out bool missingValue) - { - missingValue = false; - for (var i = 0; i < args.Length; i++) - { - if (!string.Equals(args[i], name, StringComparison.Ordinal)) continue; - if (i + 1 >= args.Length) - { - missingValue = true; - return null; - } - return args[i + 1]; - } - return null; - } static void CopyDirectory(string src, string dst) { diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index fa6fb31c5..822010dd8 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -50,230 +50,17 @@ public void ShortNames_are_the_canonical_name_of_each_template_in_the_pack() Assert.Contains(WinAppSdkTemplates.BlankShortName, WinAppSdkTemplates.ShortNames); } - [Fact] - public void SelectPreferStable_prefers_a_stable_over_a_higher_prerelease() - { - // A plain "highest SemVer" pick returns 1.1.0-alpha.1 here because its - // core triple is higher. For a developer bootstrap we want the shipped - // stable instead. - var published = new[] { "1.0.0", "1.1.0-alpha.1", "0.9.0" }; - - Assert.Equal("1.0.0", WinAppSdkTemplates.SelectPreferStable(published)); - } - - [Fact] - public void SelectPreferStable_falls_back_to_newest_prerelease_when_no_stable_exists() - { - // The state the pack was actually in when this migration landed: only - // prereleases published. Returning null here would make bootstrap fall - // back to a bare package id, which `dotnet new install` then fails to - // resolve (stable-only) — the exact breakage this logic prevents. - var published = new[] { "0.0.4-alpha", "0.0.6-alpha", "0.0.5-alpha" }; - - Assert.Equal("0.0.6-alpha", WinAppSdkTemplates.SelectPreferStable(published)); - } - - [Fact] - public void SelectPreferStable_orders_prereleases_numerically_not_lexically() - { - // A string sort ranks "alpha.9" above "alpha.10". Pinning the older - // template pack is a silent downgrade, not a hard failure, so assert it. - var published = new[] { "0.0.6-alpha.9", "0.0.6-alpha.10", "0.0.6-alpha.2" }; - - var latest = WinAppSdkTemplates.SelectPreferStable(published); - - Assert.Equal("0.0.6-alpha.10", latest); - Assert.NotEqual("0.0.6-alpha.9", latest); - } - - [Fact] - public void SelectPreferStable_returns_null_for_no_versions() - { - // Empty feed / unreachable index. Callers treat null as "couldn't - // resolve" and fall back to the bare package id rather than installing - // a bogus "id::" spec. - Assert.Null(WinAppSdkTemplates.SelectPreferStable(Array.Empty())); - } - - [Fact] - public void EnumerateLocalVersions_reads_versions_off_nupkg_filenames() - { - // The `-WinAppSdkTemplatesSource ` path used to test an - // unpublished build of the pack: resolution reads the folder rather - // than querying NuGet. - var dir = global::System.IO.Path.Join( - global::System.IO.Path.GetTempPath(), - $"wasdk-templates-{Guid.NewGuid():N}"); - global::System.IO.Directory.CreateDirectory(dir); - try - { - var id = WinAppSdkTemplates.PackageId; - global::System.IO.File.WriteAllText(global::System.IO.Path.Join(dir, $"{id}.0.0.6-alpha.nupkg"), ""); - global::System.IO.File.WriteAllText(global::System.IO.Path.Join(dir, $"{id}.0.0.7-alpha.nupkg"), ""); - // An unrelated package in the same folder must not be picked up. - global::System.IO.File.WriteAllText(global::System.IO.Path.Join(dir, "Microsoft.UI.Reactor.9.9.9.nupkg"), ""); - - var versions = WinAppSdkTemplates.EnumerateLocalVersions(dir); - - Assert.Equal(2, versions.Count); - Assert.Contains("0.0.6-alpha", versions); - Assert.Contains("0.0.7-alpha", versions); - Assert.DoesNotContain("9.9.9", versions); - Assert.Equal("0.0.7-alpha", WinAppSdkTemplates.SelectPreferStable(versions)); - } - finally - { - try { global::System.IO.Directory.Delete(dir, recursive: true); } - catch (Exception ex) when (ex is global::System.IO.IOException or UnauthorizedAccessException) { /* best-effort */ } - } - } - [Fact] - public void EnumerateLocalVersions_returns_empty_for_a_missing_folder() - { - var missing = global::System.IO.Path.Join( - global::System.IO.Path.GetTempPath(), - $"wasdk-templates-missing-{Guid.NewGuid():N}"); - Assert.Empty(WinAppSdkTemplates.EnumerateLocalVersions(missing)); - } - // ── Destructive-install guard ────────────────────────────────────────── - // - // Observed for real during this migration: `dotnet new install --force` - // uninstalls the existing package *before* downloading the replacement. With - // a bare package id (no version) and only prereleases published, NuGet then - // reported "the package does not exist" — and the machine was left with **no** - // templates installed at all. Exit code 103, working install destroyed. - // - // The install path therefore must never combine `--force` with a spec it - // hasn't confirmed exists. These tests pin the two properties that prevent it. - [Fact] - public void ResolveLatestVersion_returns_null_for_an_empty_local_source() - { - // This is the input that produced the destructive case: nothing resolvable. - // Returning null is what lets Install() choose the non-destructive branch, - // so a null here is load-bearing, not an edge case. - var dir = global::System.IO.Path.Join( - global::System.IO.Path.GetTempPath(), - $"wasdk-templates-empty-{Guid.NewGuid():N}"); - global::System.IO.Directory.CreateDirectory(dir); - try - { - Assert.Null(WinAppSdkTemplates.ResolveLatestVersion(dir)); - } - finally - { - try { global::System.IO.Directory.Delete(dir, recursive: true); } - catch (Exception ex) when (ex is global::System.IO.IOException or UnauthorizedAccessException) { /* best-effort */ } - } - } - // ── Destructive-install decision table ───────────────────────────────── - // - // `dotnet new install --force` uninstalls the existing package BEFORE - // downloading the replacement, so a failed install leaves the machine with no - // templates at all. Observed for real: `--force` with a spec that did not - // resolve uninstalled a working prerelease and then failed with exit 103. - // - // `PlanInstall` is the pure decision that governs when `--force` is used, so - // these drive the real behaviour rather than grepping the source for a string. - [Theory] - // No target resolved: keep whatever is installed; never force. - [InlineData("0.0.6-alpha", null, false, false, "KeepExisting")] - // Nothing installed and nothing resolved: a plain install can't destroy anything. - [InlineData(null, null, false, false, "PlainInstall")] - // Nothing installed: plain install even for a confirmed target (no --force needed). - [InlineData(null, "0.0.7-alpha", true, false, "PlainInstall")] - // Same version already installed, no source: no-op. - [InlineData("0.0.7-alpha", "0.0.7-alpha", true, false, "AlreadyCurrent")] - // Replacing an install with a CONFIRMED version is the only forced path. - [InlineData("0.0.6-alpha", "0.0.7-alpha", true, false, "ForcedReplace")] - // THE REGRESSION: a pin that could not be confirmed must NOT force. - [InlineData("0.0.6-alpha", "0.0.9-nope", false, false, "RefuseUnverifiedPin")] - // An explicit source means "install from here", so an equal version still installs. - [InlineData("0.0.7-alpha", "0.0.7-alpha", true, true, "ForcedReplace")] - // With a source, a version that isn't in it must be refused even with nothing - // installed: --add-source only ADDS a feed, so `::` would be - // satisfied from nuget.org instead — a different package, same version string. - [InlineData(null, "0.0.7-alpha", false, true, "RefuseUnverifiedPin")] - [InlineData("0.0.6-alpha", "0.0.7-alpha", false, true, "RefuseUnverifiedPin")] - // Same hazard with no version resolvable at all (an empty --source folder): - // a bare package id resolves from the configured feeds, not from the folder. - [InlineData(null, null, false, true, "RefuseUnverifiedPin")] - [InlineData("0.0.6-alpha", null, false, true, "RefuseUnverifiedPin")] - public void PlanInstall_only_forces_for_a_confirmed_target( - string? installed, string? target, bool targetExists, bool hasSource, string expected) - { - var actual = WinAppSdkTemplates.PlanInstall(installed, target, targetExists, hasSource); - Assert.Equal(expected, actual.ToString()); - } - [Fact] - public void PlanInstall_never_installs_from_an_unconfirmed_source() - { - // Property form: whenever an explicit --source was given, no action that - // shells out to `dotnet new install` may be chosen unless the target was - // confirmed to exist in that source. Otherwise `--add-source` silently - // resolves the package from a different feed. - foreach (var installed in new[] { null, "0.0.6-alpha" }) - foreach (var target in new[] { null, "0.0.7-alpha" }) - foreach (var exists in new[] { true, false }) - { - var action = WinAppSdkTemplates.PlanInstall(installed, target, exists, hasSource: true); - if (action is WinAppSdkTemplates.InstallAction.PlainInstall - or WinAppSdkTemplates.InstallAction.ForcedReplace) - { - Assert.True(exists && target is not null, - $"PlanInstall chose {action} against an unconfirmed --source " + - $"(installed={installed ?? "null"}, target={target ?? "null"}, targetExists={exists})."); - } - } - } - [Fact] - public void PlanInstall_never_forces_an_unconfirmed_target() - { - // Property form of the row above: across every combination, ForcedReplace - // must imply targetExists. This is the invariant that keeps a bad pin from - // uninstalling a working pack. - foreach (var installed in new[] { null, "0.0.6-alpha" }) - foreach (var target in new[] { null, "0.0.7-alpha" }) - foreach (var exists in new[] { true, false }) - foreach (var hasSource in new[] { true, false }) - { - var action = WinAppSdkTemplates.PlanInstall(installed, target, exists, hasSource); - if (action == WinAppSdkTemplates.InstallAction.ForcedReplace) - { - Assert.True(exists, $"PlanInstall forced a replace for an unconfirmed target " + - $"(installed={installed}, target={target ?? "null"}, hasSource={hasSource})."); - Assert.NotNull(installed); - } - } - } - // ── Credential redaction in the echoed command line ──────────────────── - [Theory] - [InlineData("https://user:pat@pkgs.example.com/v3/index.json", "pat")] - [InlineData("https://pkgs.example.com/v3/index.json?api-key=SECRET", "SECRET")] - public void RedactSource_strips_credentials_from_feed_urls(string url, string secret) - { - // The install command line is echoed to the console and into CI logs. - var redacted = WinAppSdkTemplates.RedactSource(url); - Assert.DoesNotContain(secret, redacted, StringComparison.Ordinal); - Assert.Contains("pkgs.example.com", redacted, StringComparison.Ordinal); - } - [Fact] - public void RedactSource_leaves_local_folder_paths_alone() - { - // A folder path carries nothing secret and must stay readable in the echo. - const string folder = @"C:\src\WindowsAppSDK\localpackages"; - Assert.Equal(folder, WinAppSdkTemplates.RedactSource(folder)); - } // ── Installed-version parsing ────────────────────────────────────────── @@ -365,164 +152,15 @@ Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank)); } - [Theory] - // The property under test is simply "the secret never reaches the console". - // Which branch enforces it varies, and not obviously: `Uri` rejects user-info - // on the file scheme outright, so that row is the one that lands in - // RedactSource's TryCreate-failure path — the path that used to echo its input - // verbatim, and the path that runs on every *rejected* --source, i.e. exactly - // the values a user is most likely to have typed a PAT into. (Mutation-checked: - // removing the unparsable-URL masking reddens that row and only that row.) - [InlineData("file://user:pat@host/share/pkgs", "pat")] - [InlineData("https://user:SECRET@pkgs.example.com/v3/index.json[", "SECRET")] - [InlineData("https://pkgs.example.com/v3/index.json[?api-key=SECRET", "SECRET")] - [InlineData("https://pkgs.example.com/v3/index.json[#SECRET", "SECRET")] - public void RedactSource_masks_url_like_values_carrying_secrets(string source, string secret) - { - Assert.DoesNotContain(secret, WinAppSdkTemplates.RedactSource(source), StringComparison.Ordinal); - } - - [Theory] - // Local paths have nothing to mask, and mangling them would make the error - // messages that quote them useless. - [InlineData(@"C:\repo\local-nupkgs")] - [InlineData(@"\\server\share\pkgs")] - [InlineData("./pkgs")] - public void RedactSource_leaves_path_like_values_alone(string source) - { - Assert.Equal(source, WinAppSdkTemplates.RedactSource(source)); - } - - [Fact] - public void RedactSource_strips_a_query_from_a_file_uri() - { - // A file URI is IsFile, so a blanket "local path, nothing to hide" early - // return skipped masking entirely. User-info is not constructible on the - // file scheme (Uri rejects it), but a query or fragment is — and either can - // carry a token. - var redacted = WinAppSdkTemplates.RedactSource("file://host/share/pkgs?token=SECRET"); - Assert.DoesNotContain("SECRET", redacted, StringComparison.Ordinal); - - // A plain local path still passes through untouched. - Assert.Equal(@"C:\repo\local-nupkgs", WinAppSdkTemplates.RedactSource(@"C:\repo\local-nupkgs")); - } - - [Fact] - public void RedactSource_strips_a_credential_bearing_fragment() - { - // UriBuilder preserves the fragment, so it has to be masked explicitly. - var redacted = WinAppSdkTemplates.RedactSource("https://feed.example.com/v3/index.json#PAT"); - Assert.DoesNotContain("PAT", redacted, StringComparison.Ordinal); - } - - [Fact] - public void ParsePackageBaseAddress_reads_the_flat_container_from_a_service_index() - { - // The service-index host generally has no /flatcontainer/ path of its own, - // so guessing one 404s for every package — including ones that exist. The - // base address has to come out of the index. - const string serviceIndex = """ - { - "version": "3.0.0", - "resources": [ - { "@id": "https://example.com/query", "@type": "SearchQueryService/3.0.0" }, - { "@id": "https://ms-feed-25.example.com/_packaging/x/nuget/v3/flat2", "@type": "PackageBaseAddress/3.0.0" } - ] - } - """; - Assert.Equal( - "https://ms-feed-25.example.com/_packaging/x/nuget/v3/flat2/", - WinAppSdkTemplates.ParsePackageBaseAddress(serviceIndex)); - } - [Fact] - public void ParsePackageBaseAddress_returns_null_when_no_flat_container_is_declared() - { - // Must be null, not a guessed URL: the caller falls back to nuget.org, and - // a fabricated address would instead 404 and read as "version absent". - const string serviceIndex = """ - {"version":"3.0.0","resources":[{"@id":"https://example.com/query","@type":"SearchQueryService/3.0.0"}]} - """; - Assert.Null(WinAppSdkTemplates.ParsePackageBaseAddress(serviceIndex)); - Assert.Null(WinAppSdkTemplates.ParsePackageBaseAddress("not json at all")); - } - [Fact] - public void Bootstrap_passes_its_configured_feed_to_the_version_resolver() - { - // The resolver otherwise only knows nuget.org. On a machine that reaches - // the configured mirror but not nuget.org it would resolve nothing and fall - // back to a bare package id, which cannot reach a prerelease-only pack — - // failing the step with a usable feed sitting right there. - var (path, text) = ReadRepoFile("bootstrap.ps1"); - var normalized = text.Replace("\r\n", "\n"); - Assert.True( - global::System.Text.RegularExpressions.Regex.IsMatch(normalized, @"\$templateFeed\s*=\s*\$effectiveNuGetSource"), - $"'{path}' must seed the template version feed from the resolved NuGet source."); - Assert.True( - global::System.Text.RegularExpressions.Regex.IsMatch(normalized, @"'--feed',\s*\$templateFeed"), - $"'{path}' must pass that feed to `mur templates install --feed`."); - } - [Theory] - // Version metadata is what picks the package to install, so plaintext lets a - // network attacker choose the version; credentials in the URL would be sent to - // whatever endpoint the URL names. - [InlineData("https://pkgs.example.com/v3/index.json", true)] - [InlineData("http://localhost:5000/v3/index.json", true)] - [InlineData("http://127.0.0.1:5000/v3/index.json", true)] - [InlineData("http://pkgs.example.com/v3/index.json", false)] - [InlineData("https://user:pat@pkgs.example.com/v3/index.json", false)] - [InlineData("https://pkgs.example.com/v3/index.json?api-key=SECRET", false)] - [InlineData("https://pkgs.example.com/v3/index.json#SECRET", false)] - [InlineData("ftp://pkgs.example.com/v3/index.json", false)] - [InlineData("not a url", false)] - [InlineData("", false)] - public void IsAllowedFeedUrl_matches_the_bootstrap_feed_policy(string feed, bool allowed) - { - Assert.Equal(allowed, WinAppSdkTemplates.IsAllowedFeedUrl(feed)); - } - [Fact] - public void Bootstrap_derives_a_version_feed_from_an_explicit_nuget_config() - { - // An explicit -NuGetConfig reaches restore as `--configfile`, so it never - // produces a bare source URL. Without this the explicit-mirror path falls - // back to nuget.org for version lookup and resolves nothing on a machine - // that can only reach the mirror. - var (path, text) = ReadRepoFile("bootstrap.ps1"); - Assert.True( - global::System.Text.RegularExpressions.Regex.IsMatch( - text.Replace("\r\n", "\n"), - @"Get-ReactorFeedSourceFromConfig\s+-ConfigPath\s+\$effectiveNuGetConfig"), - $"'{path}' must read a version feed out of an explicitly selected NuGet config."); - var (resolverPath, resolver) = ReadRepoFile(global::System.IO.Path.Join("tools", "BootstrapFeedResolver.ps1")); - Assert.True( - resolver.Contains("function Get-ReactorFeedSourceFromConfig", StringComparison.Ordinal), - $"'{resolverPath}' must define Get-ReactorFeedSourceFromConfig."); - } - [Theory] - // A validated service index can still *advertise* an unsafe base address, and - // following it would fetch version metadata — the thing that selects the - // package — over plaintext, or send URL credentials to that endpoint. - [InlineData("http://evil.example.com/flat2/", false)] - [InlineData("https://user:pat@feed.example.com/flat2/", false)] - [InlineData("https://feed.example.com/flat2/", true)] - public void IsAllowedFeedUrl_also_gates_an_advertised_base_address(string advertised, bool allowed) - { - var serviceIndex = $$""" - {"version":"3.0.0","resources":[{"@id":"{{advertised}}","@type":"PackageBaseAddress/3.0.0"}]} - """; - // Parsing is deliberately permissive — the policy check is what stops it. - var parsed = WinAppSdkTemplates.ParsePackageBaseAddress(serviceIndex); - Assert.NotNull(parsed); - Assert.Equal(allowed, WinAppSdkTemplates.IsAllowedFeedUrl(parsed)); - } [Fact] public void Bootstrap_does_not_advertise_dotnet_new_reactor_when_templates_are_skipped() @@ -543,153 +181,34 @@ public void Bootstrap_does_not_advertise_dotnet_new_reactor_when_templates_are_s } [Fact] - public void Upgrade_verifies_template_availability_after_installing() + public void Upgrade_reports_template_availability_without_installing() { - // `mur upgrade` reporting success on an installed-but-unusable pack is the - // same false PASS bootstrap and `mur templates install` already guard. + // `mur upgrade` no longer installs the pack — `winapp` owns that — so the + // guard is that it still *probes* and points somewhere useful, rather than + // silently dropping the check or claiming to have refreshed anything. var (path, text) = ReadRepoFile(global::System.IO.Path.Join("src", "Reactor.Cli", "Upgrade", "UpgradeCommand.cs")); Assert.True( - text.Contains("AreTemplatesAvailable() == false", StringComparison.Ordinal), - $"'{path}' must check template availability after Install, not just the install outcome."); - - // Install() runs `dotnet new install --add-source` with repoRoot as the - // working directory, so a relative --templates-source resolved there would - // name a different folder than the one the caller typed. + text.Contains("AreTemplatesAvailable()", StringComparison.Ordinal), + $"'{path}' must still probe template availability during upgrade."); Assert.True( - global::System.Text.RegularExpressions.Regex.IsMatch( - text.Replace("\r\n", "\n"), - @"templateSource\s*=\s*Path\.GetFullPath\(templateSource!\)"), - $"'{path}' must resolve a relative --templates-source against the caller's CWD."); - } - - // ── Redirect policy on the version-metadata fetch ───────────────────── - // - // HttpClient follows redirects by default, which would defeat - // IsAllowedFeedUrl entirely: a validated HTTPS service index can 302 to - // plaintext HTTP and the body would be accepted without the policy ever - // seeing that address. The version list is what selects the package to - // install, so that is a real downgrade vector. - - sealed class StubHandler : global::System.Net.Http.HttpMessageHandler - { - readonly global::System.Collections.Generic.Queue _responses; - readonly global::System.Net.Http.HttpResponseMessage _exhausted = - new(global::System.Net.HttpStatusCode.NotFound); - - public global::System.Collections.Generic.List Requested { get; } = new(); - - public StubHandler(params global::System.Net.Http.HttpResponseMessage[] responses) => - _responses = new global::System.Collections.Generic.Queue(responses); - - protected override global::System.Threading.Tasks.Task SendAsync( - global::System.Net.Http.HttpRequestMessage request, - global::System.Threading.CancellationToken cancellationToken) - { - Requested.Add(request.RequestUri!.AbsoluteUri); - return global::System.Threading.Tasks.Task.FromResult( - _responses.Count > 0 ? _responses.Dequeue() : _exhausted); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _exhausted.Dispose(); - while (_responses.Count > 0) _responses.Dequeue().Dispose(); - } - base.Dispose(disposing); - } - } - - static global::System.Net.Http.HttpResponseMessage Redirect(string location) - { - var response = new global::System.Net.Http.HttpResponseMessage(global::System.Net.HttpStatusCode.Found); - response.Headers.Location = new Uri(location); - return response; - } - - [Fact] - public void GetStringPolicyChecked_refuses_a_redirect_that_downgrades_to_plaintext() - { - var handler = new StubHandler(Redirect("http://evil.example.com/flat2/index.json")); - using var http = new global::System.Net.Http.HttpClient(handler); - - var body = WinAppSdkTemplates.GetStringPolicyChecked(http, "https://feed.example.com/v3/index.json"); - - Assert.Null(body); - // The load-bearing half: the plaintext hop must never be requested at all. - Assert.Equal(new[] { "https://feed.example.com/v3/index.json" }, handler.Requested); - } - - [Fact] - public void GetStringPolicyChecked_refuses_a_redirect_that_carries_credentials() - { - var handler = new StubHandler(Redirect("https://user:pat@feed.example.com/flat2/index.json")); - using var http = new global::System.Net.Http.HttpClient(handler); - - Assert.Null(WinAppSdkTemplates.GetStringPolicyChecked(http, "https://feed.example.com/v3/index.json")); - Assert.Single(handler.Requested); - } - - [Fact] - public void GetStringPolicyChecked_follows_a_compliant_redirect() - { - // The negative cases above prove nothing unless redirects otherwise work: - // a method that always returned null would pass them. - var ok = new global::System.Net.Http.HttpResponseMessage(global::System.Net.HttpStatusCode.OK) - { - Content = new global::System.Net.Http.StringContent("{\"versions\":[\"1.0.0\"]}"), - }; - var handler = new StubHandler(Redirect("https://cdn.example.com/flat2/index.json"), ok); - using var http = new global::System.Net.Http.HttpClient(handler); - - var body = WinAppSdkTemplates.GetStringPolicyChecked(http, "https://feed.example.com/v3/index.json"); - - Assert.Equal("{\"versions\":[\"1.0.0\"]}", body); - Assert.Equal( - new[] { "https://feed.example.com/v3/index.json", "https://cdn.example.com/flat2/index.json" }, - handler.Requested); - } - - [Fact] - public void GetStringPolicyChecked_stops_a_redirect_loop() - { - var handler = new StubHandler( - Redirect("https://a.example.com/1"), Redirect("https://a.example.com/2"), - Redirect("https://a.example.com/3"), Redirect("https://a.example.com/4"), - Redirect("https://a.example.com/5"), Redirect("https://a.example.com/6")); - using var http = new global::System.Net.Http.HttpClient(handler); - - Assert.Null(WinAppSdkTemplates.GetStringPolicyChecked(http, "https://a.example.com/0")); - Assert.Equal(5, handler.Requested.Count); - } - - [Theory] - // The contract bootstrap.ps1 depends on: 0 = usable, 2 = installed but the - // short name does not resolve, which bootstrap must NOT treat as a fatal - // install failure (it has its own warning path and adapted next-step - // guidance). "Could not enumerate" is also 2 — unverified is not usable. - [InlineData(true, 0)] - [InlineData(false, 2)] - [InlineData(null, 2)] - public void Install_exit_code_distinguishes_unusable_from_failed(bool? available, int expected) - { - Assert.Equal(expected, TemplatesCommand.ExitCodeForAvailability(available)); - Assert.NotEqual(1, TemplatesCommand.ExitCodeForAvailability(available)); + text.Contains("winapp new", StringComparison.Ordinal), + $"'{path}' must point at `winapp new` when the templates are missing."); + // The installer is gone; nothing here may call it back into existence. + Assert.DoesNotContain("WinAppSdkTemplates.Install", text, StringComparison.Ordinal); } [Fact] public void Bootstrap_does_not_treat_an_unusable_pack_as_an_install_failure() { - // Regression: `mur templates install` returning non-zero for an installed - // but unusable pack made bootstrap Fail before it ever reached the - // verification and the gated guidance below it. + // Regression: a non-zero result for an installed-but-unusable pack (or an + // absent winapp) made bootstrap Fail before it ever reached the + // verification and the gated guidance below it. Exit 2 must fall through. var (path, text) = ReadRepoFile("bootstrap.ps1"); Assert.True( global::System.Text.RegularExpressions.Regex.IsMatch( text.Replace("\r\n", "\n"), - @"\$templatesExit -ne 0 -and \$templatesExit -ne " + TemplatesCommand.TemplatesUnavailableExit), - $"'{path}' must let exit {TemplatesCommand.TemplatesUnavailableExit} through to the verification step."); + @"\$templatesExit -ne 0 -and \$templatesExit -ne 2"), + $"'{path}' must let exit 2 through to the verification step."); } [Theory] @@ -726,92 +245,9 @@ public void Bootstrap_gives_different_advice_per_status_outcome() Assert.Contains("Could not enumerate", block, StringComparison.Ordinal); } - [Fact] - public void Install_from_a_local_folder_installs_the_nupkg_itself() - { - // THE POINT OF --source: `id::version --add-source ` leaves every - // configured feed active and NuGet queries them in parallel, so a local - // unpublished nupkg reusing a published id+version can be silently - // replaced by the public one. Enumerating the folder confirms the version, - // not which bytes get selected. Installing the file removes the ambiguity. - var dir = global::System.IO.Path.Join( - global::System.IO.Path.GetTempPath(), $"wasdk-templates-file-{Guid.NewGuid():N}"); - global::System.IO.Directory.CreateDirectory(dir); - try - { - var nupkg = global::System.IO.Path.Join(dir, $"{WinAppSdkTemplates.PackageId}.0.0.7-alpha.nupkg"); - global::System.IO.File.WriteAllText(nupkg, ""); - - var found = WinAppSdkTemplates.FindLocalPackage(dir, "0.0.7-alpha"); - Assert.Equal(nupkg, found); - Assert.Null(WinAppSdkTemplates.FindLocalPackage(dir, "0.0.9-absent")); - - var args = WinAppSdkTemplates.BuildInstallArgs( - "0.0.7-alpha", dir, feed: null, force: true, localPackagePath: found); - - Assert.Equal(new[] { "new", "install", nupkg, "--force" }, args); - // No --add-source at all: the file *is* the package, so there is no - // second candidate for NuGet to choose between. - Assert.DoesNotContain("--add-source", args); - } - finally - { - try { global::System.IO.Directory.Delete(dir, recursive: true); } - catch (Exception ex) when (ex is global::System.IO.IOException or UnauthorizedAccessException) { /* best-effort */ } - } - } - - [Fact] - public void BuildInstallArgs_adds_the_mirror_feed_when_there_is_no_folder_source() - { - // `dotnet new install` runs its own restore and ignores the MSBuild - // RestoreSources/RestoreConfigFile that Invoke-ReactorWithRestoreEnvironment - // sets, so the configured mirror has to appear here too. Resolving a version - // from the mirror and then downloading it from nowhere is the failure mode. - var args = WinAppSdkTemplates.BuildInstallArgs( - "0.0.7-alpha", source: null, feed: "https://mirror.example.com/v3/index.json", force: true); - Assert.Equal( - new[] - { - "new", "install", $"{WinAppSdkTemplates.PackageId}::0.0.7-alpha", "--force", - "--add-source", "https://mirror.example.com/v3/index.json", - }, - args); - } - [Fact] - public void Install_does_not_add_the_mirror_beside_a_local_source() - { - // THE REGRESSION: NuGet treats identical id+version candidates across - // sources as interchangeable, so a mirror listed beside the folder can - // serve the *published* 0.0.7-alpha instead of the unpublished one the - // caller pointed at — the exact collision --source exists to avoid. - // Asserted on the decision, not just on BuildInstallArgs, because the - // suppression happens in Install(). - var withBoth = WinAppSdkTemplates.BuildInstallArgs( - "0.0.7-alpha", @"C:\pkgs", feed: null, force: false); - Assert.Equal( - new[] { "new", "install", $"{WinAppSdkTemplates.PackageId}::0.0.7-alpha", "--add-source", @"C:\pkgs" }, - withBoth); - Assert.Single(withBoth, a => a == "--add-source"); - } - - [Theory] - [InlineData(null, null)] - [InlineData(@"C:\pkgs", null)] - [InlineData(null, "https://mirror.example.com/v3/index.json")] - public void BuildInstallArgs_emits_a_source_flag_only_when_it_has_a_value(string? source, string? feed) - { - var args = WinAppSdkTemplates.BuildInstallArgs("1.0.0", source, feed, force: false); - - var expected = new[] { source, feed }.Count(v => !string.IsNullOrWhiteSpace(v)); - Assert.Equal(expected, args.Count(a => a == "--add-source")); - // A dangling `--add-source` with no value would make `dotnet new install` - // swallow the next token, so the flag must never outnumber the values. - Assert.DoesNotContain("--force", args); - } [Fact] public void InterpretPackageInstalledOutput_matches_the_id_line_exactly() @@ -895,38 +331,6 @@ public void Doctor_probes_template_availability_not_just_package_presence() Assert.Contains("AreTemplatesAvailable()", text, StringComparison.Ordinal); } - // ── Outcome-reporting guard ──────────────────────────────────────────── - // - // Found by running `mur templates install` against the real published pack - // with NuGet unreachable: the guard correctly kept the installed pack and - // uninstalled nothing, but the command still printed "Installed." — telling - // the user an install had happened when none had. A bare exit code cannot - // express the difference, so Install returns an outcome instead. - - [Fact] - public void DescribeOutcome_never_claims_an_install_that_did_not_happen() - { - // Behavioural form of the reporting bug: `mur templates install` printed - // "Installed." while deliberately keeping an existing pack (nothing - // resolvable). Only the two outcomes that actually changed the machine may - // be described as an install/update. - Assert.Equal("Installed.", TemplatesCommand.DescribeOutcome(WinAppSdkTemplates.InstallOutcome.Installed)); - Assert.Equal("Updated.", TemplatesCommand.DescribeOutcome(WinAppSdkTemplates.InstallOutcome.Updated)); - - foreach (var unchanged in new[] - { - WinAppSdkTemplates.InstallOutcome.KeptExisting, - WinAppSdkTemplates.InstallOutcome.AlreadyCurrent, - WinAppSdkTemplates.InstallOutcome.Failed, - }) - { - var message = TemplatesCommand.DescribeOutcome(unchanged); - Assert.False( - message.Contains("Installed.", StringComparison.Ordinal) || - message.Contains("Updated.", StringComparison.Ordinal), - $"{unchanged} did not change the machine but is reported as \"{message}\"."); - } - } // ── Bootstrap wiring guards ──────────────────────────────────────────── // @@ -942,14 +346,22 @@ public void DescribeOutcome_never_claims_an_install_that_did_not_happen() public void Bootstrap_installs_the_windows_app_sdk_template_pack() { var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); Assert.Contains("Microsoft.WindowsAppSDK.WinUI.CSharp.Templates", text, StringComparison.Ordinal); + // `winapp new --list` installs the pack on demand; --use-defaults keeps an + // already-installed one and never prompts, which is bootstrap's + // install-if-missing (not reinstall) semantics on a non-interactive run. Assert.True( global::System.Text.RegularExpressions.Regex.IsMatch( - text.Replace("\r\n", "\n"), @"templates',\s*'install'"), - $"'{path}' must install the Reactor templates via `mur templates install`, which resolves the " + - "newest published version of the Windows App SDK template pack. `dotnet new install` has no " + - "--prerelease switch and resolves stable-only, so installing the bare package id fails while " + - "the pack is prerelease-only."); + normalized, @"'new',\s*'--list',\s*'--use-defaults'"), + $"'{path}' must install the Reactor templates via `winapp new --list --use-defaults`, which " + + "installs the Windows App SDK template pack on demand. `dotnet new install` has no --prerelease " + + "switch and resolves stable-only, so installing the bare package id fails while the pack is " + + "prerelease-only — that is the whole reason this does not shell out to `dotnet new install`."); + // The in-repo installer is gone; bootstrap may not call it back. + Assert.False( + global::System.Text.RegularExpressions.Regex.IsMatch(normalized, @"templates',\s*'install'"), + $"'{path}' must not call the removed `mur templates install`."); } [Fact] @@ -1049,50 +461,46 @@ public sealed class TemplatesCommandArgvTests } } + + [Fact] - public void Install_help_succeeds_without_installing_anything() + public void Unknown_subcommand_and_no_subcommand_both_show_help() { - // `mur templates install --help` used to fall straight through to a real - // install, so this asserts the help text *and* the absence of the install - // banner rather than just the exit code. - var (exitCode, stdout, _) = Run("install", "--help"); + var (missing, missingOut, _) = Run(); + Assert.Equal(1, missing); + Assert.Contains("mur templates", missingOut, StringComparison.Ordinal); - Assert.Equal(0, exitCode); - Assert.Contains("Usage: mur templates install", stdout, StringComparison.Ordinal); - Assert.DoesNotContain("Installing " + WinAppSdkTemplates.PackageId, stdout, StringComparison.Ordinal); + var (unknown, _, unknownErr) = Run("instal"); + Assert.Equal(1, unknown); + Assert.Contains("instal", unknownErr, StringComparison.Ordinal); } - [Theory] - // A typo must fail loudly, not install from somewhere else. - [InlineData(new[] { "install", "--sorce", "./pkgs" }, "unknown option")] - [InlineData(new[] { "install", "-x" }, "unknown option")] - // A bare positional is never meaningful here. - [InlineData(new[] { "install", "0.0.7-alpha" }, "unexpected argument")] - // A flag with no value would otherwise silently install the resolved latest. - [InlineData(new[] { "install", "--source" }, "requires a value")] - [InlineData(new[] { "install", "--version" }, "requires a value")] - [InlineData(new[] { "install", "--feed" }, "requires a value")] - // A following flag is not a value. - [InlineData(new[] { "install", "--source", "--version", "1.0.0" }, "requires a value")] - public void Install_rejects_bad_argv(string[] args, string expected) + [Fact] + public void Removed_install_subcommand_names_its_replacement() { - var (exitCode, stdout, stderr) = Run(args); + // `install` is handled explicitly rather than falling into "unknown + // subcommand": it existed, bootstrap and the docs called it, and anyone + // with it in muscle memory or a script needs to be sent to `winapp new` + // — not left hunting for a typo. + var (exitCode, _, stderr) = Run("install"); Assert.Equal(1, exitCode); - Assert.Contains(expected, stderr, StringComparison.Ordinal); - // Nothing may have been installed on the way to the error. - Assert.DoesNotContain("Installing " + WinAppSdkTemplates.PackageId, stdout, StringComparison.Ordinal); + Assert.Contains("has been removed", stderr, StringComparison.Ordinal); + Assert.Contains("winapp new", stderr, StringComparison.Ordinal); + // A bare "unknown subcommand 'install'" would be the unhelpful outcome. + Assert.DoesNotContain("unknown subcommand", stderr, StringComparison.Ordinal); } [Fact] - public void Unknown_subcommand_and_no_subcommand_both_show_help() + public void Help_documents_the_status_exit_codes() { - var (missing, missingOut, _) = Run(); - Assert.Equal(1, missing); - Assert.Contains("mur templates", missingOut, StringComparison.Ordinal); + // bootstrap.ps1 branches on these, so they are contract, not cosmetics. + var (exitCode, stdout, _) = Run("--help"); - var (unknown, _, unknownErr) = Run("instal"); - Assert.Equal(1, unknown); - Assert.Contains("instal", unknownErr, StringComparison.Ordinal); + Assert.Equal(0, exitCode); + Assert.Contains("Exit codes:", stdout, StringComparison.Ordinal); + Assert.Contains("winapp new", stdout, StringComparison.Ordinal); + // The removed installer must not be advertised as an option any more. + Assert.DoesNotContain("mur templates install", stdout, StringComparison.Ordinal); } -} \ No newline at end of file +} diff --git a/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 b/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 index e2419eaad..b3e6dad3a 100644 --- a/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 +++ b/tests/vs_reactor/ci/BootstrapFeedResolver.Tests.ps1 @@ -211,65 +211,6 @@ try { Assert-Throws { Resolve-ReactorNuGetFeed -ExplicitConfig (Join-Path $tmp 'missing.config') } ` 'missing explicit NuGet config is rejected' - # Get-ReactorFeedSourceFromConfig — an explicit -NuGetConfig reaches restore as - # `--configfile` and yields no bare source URL, so the template version lookup - # has to read one out of the config. Exercise the parsing for real: a source-text - # assertion that the function exists would survive any XPath or filtering bug, - # and the failure mode is silent (version lookup falls back to nuget.org, which - # a mirror-only machine cannot reach). - $mirrorConfig = Join-Path $tmp 'mirror.config' - Set-Content $mirrorConfig @' - - - - - - - - -'@ - Assert-Equal 'https://packagefeedproxy.microsoft.io/nuget/v3/index.json' ` - (Get-ReactorFeedSourceFromConfig -ConfigPath $mirrorConfig) ` - 'explicit config prefers the proxy mirror, skips local folders, and trims the trailing slash' - - $disabledConfig = Join-Path $tmp 'disabled.config' - Set-Content $disabledConfig @' - - - - - - - - - - -'@ - Assert-Equal 'https://other.example.test/nuget/v3/index.json' ` - (Get-ReactorFeedSourceFromConfig -ConfigPath $disabledConfig) ` - 'a disabled source is skipped even when it is the preferred mirror' - - $localOnlyConfig = Join-Path $tmp 'local-only.config' - Set-Content $localOnlyConfig @' - - - - - - - - -'@ - Assert-Equal $null (Get-ReactorFeedSourceFromConfig -ConfigPath $localOnlyConfig) ` - 'a config with no policy-passing feed URL yields no version feed' - - $malformedConfig = Join-Path $tmp 'malformed.config' - Set-Content $malformedConfig '' - Assert-Equal $null (Get-ReactorFeedSourceFromConfig -ConfigPath $malformedConfig) ` - 'malformed XML yields no version feed instead of throwing' - - Assert-Equal $null (Get-ReactorFeedSourceFromConfig -ConfigPath (Join-Path $tmp 'nope.config')) ` - 'a missing config yields no version feed' $restoreArgs = Get-ReactorRestoreArguments ` -NuGetSource 'https://packagefeedproxy.microsoft.io/nuget/v3/index.json' ` diff --git a/tools/BootstrapFeedResolver.ps1 b/tools/BootstrapFeedResolver.ps1 index b3d6c337c..35d623a08 100644 --- a/tools/BootstrapFeedResolver.ps1 +++ b/tools/BootstrapFeedResolver.ps1 @@ -161,56 +161,6 @@ function Resolve-ReactorNuGetFeed { # # Returns $null when nothing is configured, which is the public-contributor # path: no restore override, repo nuget.config stays in effect. -function Get-ReactorFeedSourceFromConfig { - <# - .SYNOPSIS - First usable package-feed URL declared by a NuGet.config. - - .DESCRIPTION - An explicitly selected config is passed to restore as `--configfile`, so - its sources never surface as a bare URL. Version *lookup* for the Windows - App SDK template pack needs one, though: without it the resolver only - knows nuget.org, and a machine that reaches the configured mirror but not - nuget.org resolves nothing. - - Prefers packagefeedproxy.microsoft.io when the config lists it, matching - Resolve-ReactorNuGetFeed's detection order; otherwise takes the first - enabled source that passes the feed-URL policy. Returns $null when the - config declares none (an all-local-folder config, say). - #> - param( - [Parameter(Mandatory)][string]$ConfigPath - ) - - if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { return $null } - - try { - [xml]$xml = Get-Content -LiteralPath $ConfigPath -Raw - } catch { - return $null - } - - $disabled = @{} - foreach ($entry in @($xml.SelectNodes('//disabledPackageSources/add'))) { - if ([string]$entry.value -eq 'true') { $disabled[[string]$entry.key] = $true } - } - - $candidates = New-Object System.Collections.Generic.List[string] - foreach ($source in @($xml.SelectNodes('//packageSources/add'))) { - if ($disabled.ContainsKey([string]$source.key)) { continue } - $value = [string]$source.value - if (-not (Test-ReactorPackageFeedUrl $value)) { continue } - $candidates.Add($value.Trim().TrimEnd('/')) - } - - if ($candidates.Count -eq 0) { return $null } - - foreach ($candidate in $candidates) { - if (([Uri]$candidate).Host -eq 'packagefeedproxy.microsoft.io') { return $candidate } - } - return $candidates[0] -} - function Resolve-ReactorNuGetFeedOverride { param( [string]$NuGetConfig, From 9916f1eedbd98a50a91a9c299ab817f3548a55ae Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 16:43:00 -0700 Subject: [PATCH 31/37] Fix fallout from retiring the in-repo template installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on the winapp migration — six code/doc findings, all introduced by that refactor. Probe classification. `AreTemplatesAvailable` treated any readable output as an answer, so an SDK/engine failure whose stderr happens not to contain "No templates found" was reported as a definite *missing* template — making `mur doctor` fail and bootstrap advise a reinstall on the strength of an error it never parsed. The exit code now survives capture: 103 is the engine's documented "no match" and is an answer, 0 is a listing, anything else is null ("couldn't tell"). The not-found marker still wins when present, since it is a real answer regardless of exit code. Mutation-checked — dropping the exit-code arm reddens the engine-failure case and nothing else. bootstrap invoked a bare `winapp` while `Test-WinAppCli` deliberately also accepts the app-execution alias, which exists on disk without always being resolvable through the current PATH. So on exactly the machines the fallback exists for, the step skipped its warning and then failed to launch anything. `Get-WinAppCliPath` now returns the concrete path and bootstrap invokes that. `mur doctor` compared `IsPackageInstalled()` to `true` only, so null ("could not read the installed-package list") fell into the "not registered" FAIL — the same ProbeFailed distinction `StatusExitCode` already draws. It now WARNs. Docs and help that still described the old behaviour: `mur --help` advertised `mur templates` as an installer, and both the dev-tooling table and the getting-started "After git pull" block said `mur upgrade` refreshes the templates. It only checks them now; bootstrap installs, `winapp` updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- bootstrap.ps1 | 32 ++++++-- docs/_pipeline/templates/dev-tooling.md.dt | 2 +- .../_pipeline/templates/getting-started.md.dt | 9 ++- docs/guide/dev-tooling.md | 2 +- docs/guide/getting-started.md | 9 ++- src/Reactor.Cli/Doctor/DoctorCommand.cs | 40 +++++++--- src/Reactor.Cli/Program.cs | 4 +- .../Templates/WinAppSdkTemplates.cs | 70 +++++++++++++----- .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 73 ++++++++++++++++++- 9 files changed, 194 insertions(+), 47 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index d3e70d69f..012708f17 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -438,11 +438,27 @@ if (-not $winAppRuntimeId) { # works out of the box — a missing winget only warns, it never fails bootstrap. Write-Step 'Checking winapp CLI (E2E UI test driver)' -function Test-WinAppCli { - if (Get-Command winapp -ErrorAction SilentlyContinue) { return $true } +function Get-WinAppCliPath { + <# + .SYNOPSIS + Path to the winapp CLI, or $null when it isn't installed. + + .DESCRIPTION + Prefers PATH, then the app-execution alias winget's MSIX install drops. + Callers must invoke *this* result rather than a bare `winapp`: the alias + exists on disk without necessarily being resolvable through the current + process's PATH, which is the whole reason the fallback exists. + #> + $onPath = Get-Command winapp -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\winapp.exe' Write-Dbg "winapp not on PATH; probing alias $alias" - return (Test-Path $alias) + if (Test-Path $alias) { return $alias } + return $null +} + +function Test-WinAppCli { + return $null -ne (Get-WinAppCliPath) } if ($SkipWinAppCli) { @@ -697,7 +713,8 @@ if ($SkipTemplates) { } $templatesExit = 0 - if (-not (Test-WinAppCli)) { + $winAppExe = Get-WinAppCliPath + if (-not $winAppExe) { # winapp is installed in step 4; it can legitimately be absent when that # step was skipped or winget is unavailable. Don't fail the whole # bootstrap over it — say what to run and let the verification below @@ -708,8 +725,11 @@ if ($SkipTemplates) { Write-Host ' winapp new --list' $templatesExit = 2 } else { - Write-Dbg "winapp $($winAppNewArgs -join ' ')" - & winapp @winAppNewArgs | Out-Null + # Invoke the resolved path, not a bare `winapp`: Get-WinAppCliPath also + # accepts the app-execution alias, which exists on disk without always + # being resolvable through this process's PATH. + Write-Dbg "$winAppExe $($winAppNewArgs -join ' ')" + & $winAppExe @winAppNewArgs | Out-Null $templatesExit = $LASTEXITCODE } # Exit 2 is bootstrap's own "winapp unavailable" marker above. Treat it the diff --git a/docs/_pipeline/templates/dev-tooling.md.dt b/docs/_pipeline/templates/dev-tooling.md.dt index 80b79052c..d668fcd5b 100644 --- a/docs/_pipeline/templates/dev-tooling.md.dt +++ b/docs/_pipeline/templates/dev-tooling.md.dt @@ -161,7 +161,7 @@ subcommands map one-to-one to the workflows below. | `mur devtools` | Launch the project with `--devtools run`, supervise reloads, and host the MCP endpoint | `mur devtools` | | `mur check` | Repo-health checks (cref validity, namespace policy, "did you mean" suggestions) | `mur check` | | `mur doctor` | Verify the install — SDK, `mur`, local feed, templates, plugin | `mur doctor` | -| `mur upgrade` | Re-pack the framework and refresh templates + plugin after a `git pull` | `mur upgrade` | +| `mur upgrade` | Re-pack the framework and refresh the plugin after a `git pull`; checks template availability but does not install the pack (that's `winapp new`) | `mur upgrade` | | `mur figma watch` | Poll a Figma file for design changes | `mur figma watch` | | `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; scaffolded apps default to the public Reactor preview unless `--reactor-version` is supplied | `mur pack-local` | | `mur templates status` | Report whether `dotnet new reactor` resolves (exit `0` available, `1` probe failed, `2` pack too old, `3` pack missing) | `mur templates status` | diff --git a/docs/_pipeline/templates/getting-started.md.dt b/docs/_pipeline/templates/getting-started.md.dt index f4807c5d6..41b354b8d 100644 --- a/docs/_pipeline/templates/getting-started.md.dt +++ b/docs/_pipeline/templates/getting-started.md.dt @@ -105,13 +105,18 @@ The source checkout changes — your local framework snapshots, CLI and plugin do not, unless you repack them. Two options: ```powershell -mur upgrade # repacks the framework and refreshes templates + plugin -./bootstrap.ps1 # same, plus updates the `mur` global tool itself +mur upgrade # repacks the framework and refreshes the plugin +./bootstrap.ps1 # same, plus the `mur` global tool and the template pack ``` `mur upgrade` is the lightweight path. Re-run `bootstrap.ps1` when you want to pick up CLI changes (a `mur` process can't replace its own binary mid-run). +The `dotnet new reactor` templates are not repacked by either: they ship in the +Windows App SDK pack, so `git pull` never invalidates them. `mur upgrade` only +*checks* that they still resolve; `bootstrap.ps1` installs the pack when it is +missing, and `winapp new --list --template-version latest` updates it. + ### Verify the install ```powershell diff --git a/docs/guide/dev-tooling.md b/docs/guide/dev-tooling.md index 382f7a6e3..d777cd014 100644 --- a/docs/guide/dev-tooling.md +++ b/docs/guide/dev-tooling.md @@ -180,7 +180,7 @@ subcommands map one-to-one to the workflows below. | `mur devtools` | Launch the project with `--devtools run`, supervise reloads, and host the MCP endpoint | `mur devtools` | | `mur check` | Repo-health checks (cref validity, namespace policy, "did you mean" suggestions) | `mur check` | | `mur doctor` | Verify the install — SDK, `mur`, local feed, templates, plugin | `mur doctor` | -| `mur upgrade` | Re-pack the framework and refresh templates + plugin after a `git pull` | `mur upgrade` | +| `mur upgrade` | Re-pack the framework and refresh the plugin after a `git pull`; checks template availability but does not install the pack (that's `winapp new`) | `mur upgrade` | | `mur figma watch` | Poll a Figma file for design changes | `mur figma watch` | | `mur pack-local` / `mur clean-local` | Package / clean the local NuGet feed for source-built framework smoke tests; scaffolded apps default to the public Reactor preview unless `--reactor-version` is supplied | `mur pack-local` | | `mur templates status` | Report whether `dotnet new reactor` resolves (exit `0` available, `1` probe failed, `2` pack too old, `3` pack missing) | `mur templates status` | diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index df2abcd67..4cc6ee722 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -93,13 +93,18 @@ The source checkout changes — your local framework snapshots, CLI and plugin do not, unless you repack them. Two options: ```powershell -mur upgrade # repacks the framework and refreshes templates + plugin -./bootstrap.ps1 # same, plus updates the `mur` global tool itself +mur upgrade # repacks the framework and refreshes the plugin +./bootstrap.ps1 # same, plus the `mur` global tool and the template pack ``` `mur upgrade` is the lightweight path. Re-run `bootstrap.ps1` when you want to pick up CLI changes (a `mur` process can't replace its own binary mid-run). +The `dotnet new reactor` templates are not repacked by either: they ship in the +Windows App SDK pack, so `git pull` never invalidates them. `mur upgrade` only +*checks* that they still resolve; `bootstrap.ps1` installs the pack when it is +missing, and `winapp new --list --template-version latest` updates it. + ### Verify the install ```powershell diff --git a/src/Reactor.Cli/Doctor/DoctorCommand.cs b/src/Reactor.Cli/Doctor/DoctorCommand.cs index f1f75eac6..e9752445f 100644 --- a/src/Reactor.Cli/Doctor/DoctorCommand.cs +++ b/src/Reactor.Cli/Doctor/DoctorCommand.cs @@ -144,20 +144,36 @@ public static int Run(string[] args) : $"`dotnet new {WinAppSdkTemplates.BlankShortName}` available ({WinAppSdkTemplates.PackageId} {ver})"; Pass("dotnet new template", detail); } - else if (WinAppSdkTemplates.IsPackageInstalled() == true) - { - // Installed, but this version doesn't carry the Reactor templates. - // Distinct remediation from "not installed", so say so explicitly. - var ver = WinAppSdkTemplates.GetInstalledVersion() ?? "(unknown)"; - Fail("dotnet new template", - $"{WinAppSdkTemplates.PackageId} {ver} is installed but does not provide `dotnet new {WinAppSdkTemplates.BlankShortName}`. " + - $"Update to a version that ships the Reactor templates: `winapp new --list --template-version latest`."); - failures++; - } else { - Fail("dotnet new template", $"{WinAppSdkTemplates.PackageId} not registered, so `dotnet new {WinAppSdkTemplates.BlankShortName}` is unavailable. Run `./bootstrap.ps1`, or install the pack with `winapp new --list`."); - failures++; + // The short name is absent — but is the pack there at all? A null + // here means the installed-package list could not be read, which is a + // probe failure, not evidence the pack is missing. Reporting "not + // registered" then sends the developer to reinstall something that + // may already be fine. Mirrors TemplatesCommand.StatusExitCode. + var packageInstalled = WinAppSdkTemplates.IsPackageInstalled(); + if (packageInstalled is null) + { + Warn("dotnet new template", + $"`dotnet new {WinAppSdkTemplates.BlankShortName}` did not resolve, and the installed-package " + + "list could not be read — so whether the pack is present is unknown. Check with `mur templates status`."); + warnings++; + } + else if (packageInstalled.Value) + { + // Installed, but this version doesn't carry the Reactor templates. + // Distinct remediation from "not installed", so say so explicitly. + var ver = WinAppSdkTemplates.GetInstalledVersion() ?? "(unknown)"; + Fail("dotnet new template", + $"{WinAppSdkTemplates.PackageId} {ver} is installed but does not provide `dotnet new {WinAppSdkTemplates.BlankShortName}`. " + + $"Update to a version that ships the Reactor templates: `winapp new --list --template-version latest`."); + failures++; + } + else + { + Fail("dotnet new template", $"{WinAppSdkTemplates.PackageId} not registered, so `dotnet new {WinAppSdkTemplates.BlankShortName}` is unavailable. Run `./bootstrap.ps1`, or install the pack with `winapp new --list`."); + failures++; + } } // 5. Claude plugin (informational only — many devs don't use it) diff --git a/src/Reactor.Cli/Program.cs b/src/Reactor.Cli/Program.cs index bf624bc65..5ab44b1a1 100644 --- a/src/Reactor.Cli/Program.cs +++ b/src/Reactor.Cli/Program.cs @@ -138,9 +138,9 @@ void ShowHelp() Console.WriteLine(" check [path] Build and emit one-line diagnostics with skill-file pointers"); Console.WriteLine(" pack-local Pack the in-source framework to /local-nupkgs/ as 0.0.0-local"); Console.WriteLine(" clean-local Remove local packages, NuGet cache entries, and templates"); - Console.WriteLine(" templates Install the `dotnet new reactor` template pack (Windows App SDK)"); + Console.WriteLine(" templates Report whether `dotnet new reactor` resolves (install: `winapp new`)"); Console.WriteLine(" doctor Verify the install (SDK, mur, local feed, templates, plugin)"); - Console.WriteLine(" upgrade Re-pack the framework and refresh templates + plugin after `git pull`"); + Console.WriteLine(" upgrade Re-pack the framework and refresh the plugin after `git pull`"); Console.WriteLine(" figma watch Poll a Figma file for design changes"); } diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 55a09ebf5..f293a9332 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -109,28 +109,52 @@ internal static bool InterpretPackageInstalledOutput(string output) => /// public static bool? AreTemplatesAvailable() { - // `dotnet new list ` exits non-zero (103) and prints - // "No templates found matching" when nothing matches. Match on the - // short name in the output rather than the exit code alone so an - // unrelated non-zero exit doesn't read as a definitive "missing". - var output = RunCapture("new", "list", BlankShortName); + // `dotnet new list ` exits 103 and prints "No templates found + // matching" when nothing matches. Any *other* non-zero exit is an engine + // or SDK failure, not an answer — reporting it as "definitely missing" + // sends `mur doctor` and bootstrap to the wrong remediation. + var (output, exitCode) = RunCaptureWithExit("new", "list", BlankShortName); if (output is null) return null; - return InterpretTemplateListOutput(output); + return InterpretTemplateListOutput(output, exitCode); } /// /// Interprets `dotnet new list reactor` output. Split out (and internal) so /// the rule is unit-testable without shelling out to the template engine. /// - internal static bool InterpretTemplateListOutput(string output) + /// + /// The template engine's exit code. 0 is a listing; 103 is its documented + /// "no templates matched". Anything else is a failure we cannot interpret, + /// so the answer is null ("couldn't tell") rather than false. + /// + internal static bool? InterpretTemplateListOutput(string output, int exitCode) { - // The "not found" message also contains the search term ("No templates - // found matching: 'reactor'." plus a "dotnet new search reactor" hint), - // so a naive short-name substring match reports the template as present - // precisely when it is absent. Check the negative marker first. + // The negative marker is authoritative when the engine reported a + // no-match, and is checked first because the message repeats the search + // term (and prints a `dotnet new search reactor` hint), so a naive + // short-name match reports the template as present exactly when absent. if (output.Contains("No templates found", StringComparison.OrdinalIgnoreCase)) return false; + // A non-zero exit without that marker is an engine/SDK error: say + // "couldn't tell" instead of inventing a definite answer from its stderr. + if (exitCode != 0 && exitCode != NoTemplatesFoundExitCode) + return null; + + return MatchesBlankShortName(output); + } + + /// + /// `dotnet new list` exit code for "no templates matched the input" — the one + /// non-zero result that is an answer rather than a failure. + /// + internal const int NoTemplatesFoundExitCode = 103; + + /// + /// Whether the listing registers as a short name. + /// + internal static bool MatchesBlankShortName(string output) + { // Match the short name as a whole token. A plain Contains (or a \b regex) // also matches `reactor-mvu` and `winui-reactor`, because '-' is a word // boundary — so a listing that has the richer shells but not the blank @@ -191,7 +215,21 @@ internal static bool InterpretTemplateListOutput(string output) return null; } - static string? RunCapture(params string[] arguments) + static string? RunCapture(params string[] arguments) => RunCaptureWithExit(arguments).Output; + + /// + /// Runs `dotnet ` and returns its combined output + /// plus exit code. Output is null when the process could not be started. + /// + /// + /// The exit code is returned rather than swallowed because `dotnet new list` + /// uses it to distinguish "no templates matched" (103) from an engine + /// failure, and collapsing the two turns a probe failure into a confident + /// wrong answer. `dotnet new uninstall` meanwhile exits non-zero when nothing + /// is installed while still printing a usable listing, so callers that only + /// want the text keep ignoring it. + /// + static (string? Output, int ExitCode) RunCaptureWithExit(params string[] arguments) { var psi = new ProcessStartInfo("dotnet") { @@ -204,7 +242,7 @@ internal static bool InterpretTemplateListOutput(string output) try { using var proc = Process.Start(psi); - if (proc is null) return null; + if (proc is null) return (null, -1); // Drain both pipes concurrently. Reading stdout to the end first lets // `dotnet new` fill the unread stderr pipe and block before it exits — // a deadlock, not a slow path. CheckCommand documents the same hazard. @@ -212,13 +250,11 @@ internal static bool InterpretTemplateListOutput(string output) var stderrTask = proc.StandardError.ReadToEndAsync(); global::System.Threading.Tasks.Task.WaitAll(stdoutTask, stderrTask); proc.WaitForExit(); - // `dotnet new uninstall` exits non-zero when nothing is installed - // while still printing a usable listing, so don't gate on ExitCode. - return stdoutTask.Result + stderrTask.Result; + return (stdoutTask.Result + stderrTask.Result, proc.ExitCode); } catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) { - return null; + return (null, -1); } } } diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index 822010dd8..c66b1852f 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -130,7 +130,7 @@ Template Name Short Name Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] Windows/WinUI/Desktop/Reactor/Experimental """; - Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput(withoutBlank)); + Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput(withoutBlank, exitCode: 0)); } [Fact] @@ -149,7 +149,7 @@ Reactor Blank App (Experimental) reactor,reactor-blank,winui-reactor Reactor MVU App (Experimental) reactor-mvu,winui-reactor-mvu [C#] Windows/WinUI/Desktop/Reactor/Experimental """; - Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank)); + Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(withBlank, exitCode: 0)); } @@ -272,6 +272,41 @@ public void InterpretPackageInstalledOutput_matches_the_id_line_exactly() Assert.True(WinAppSdkTemplates.InterpretPackageInstalledOutput(thisPack)); } + [Fact] + public void Doctor_treats_an_unreadable_package_list_as_a_warning_not_a_missing_pack() + { + // `IsPackageInstalled()` returns null when the installed-package list + // could not be read. Comparing it to `true` alone sent that case to the + // "not registered" FAIL, telling the developer to reinstall a pack that + // may be perfectly fine — the same ProbeFailed distinction StatusExitCode + // already draws. + var (path, text) = ReadRepoFile(global::System.IO.Path.Join( + "src", "Reactor.Cli", "Doctor", "DoctorCommand.cs")); + Assert.True( + text.Contains("packageInstalled is null", StringComparison.Ordinal), + $"'{path}' must handle an unreadable installed-package list separately from a missing pack."); + Assert.False( + text.Contains("IsPackageInstalled() == true", StringComparison.Ordinal), + $"'{path}' must not collapse null (couldn't tell) into false (not installed)."); + } + + [Fact] + public void Bootstrap_invokes_the_resolved_winapp_path_not_a_bare_command() + { + // The CLI is also accepted via its app-execution alias, which exists on + // disk without always being resolvable through this process's PATH. A + // bare `winapp` therefore fails on exactly the machines the alias + // fallback exists to support. + var (path, text) = ReadRepoFile("bootstrap.ps1"); + var normalized = text.Replace("\r\n", "\n"); + Assert.True( + normalized.Contains("function Get-WinAppCliPath", StringComparison.Ordinal), + $"'{path}' must resolve the winapp CLI to a concrete path."); + Assert.True( + global::System.Text.RegularExpressions.Regex.IsMatch(normalized, @"&\s*\$winAppExe\s+@winAppNewArgs"), + $"'{path}' must invoke the resolved winapp path, not a bare `winapp`."); + } + // ── False-PASS guard: "pack installed" != "templates usable" ─────────── // // Observed live during the de-stale merge: the machine had @@ -286,6 +321,36 @@ public void InterpretPackageInstalledOutput_matches_the_id_line_exactly() // naive `output.Contains("reactor")` returns true exactly when the template // is missing. These pin the negative-marker-first rule. + [Fact] + public void InterpretTemplateListOutput_reports_unknown_for_an_engine_failure() + { + // `dotnet new list` exits 103 for "no templates matched" — an answer. Any + // other non-zero exit is an SDK/engine failure, and there is no reason to + // believe its stderr describes the template state. Reporting `false` there + // makes `mur doctor` fail and bootstrap advise a reinstall on the strength + // of an error it never parsed. + const string engineError = """ + The command could not be loaded, possibly because of a missing SDK. + """; + + Assert.Null(WinAppSdkTemplates.InterpretTemplateListOutput(engineError, exitCode: 1)); + + // The two interpretable outcomes still answer definitively. + Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput( + "No templates found matching: 'reactor'.", WinAppSdkTemplates.NoTemplatesFoundExitCode)); + Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput( + "Reactor Blank App reactor,reactor-blank [C#]", exitCode: 0)); + } + + [Fact] + public void InterpretTemplateListOutput_trusts_the_not_found_marker_over_the_exit_code() + { + // The marker is authoritative when present: some SDKs have reported the + // no-match message with a non-103 exit, and that is still a real answer. + Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput( + "No templates found matching: 'reactor'.", exitCode: 1)); + } + [Fact] public void InterpretTemplateListOutput_reports_missing_for_the_not_found_message() { @@ -301,7 +366,7 @@ public void InterpretTemplateListOutput_reports_missing_for_the_not_found_messag For details on the exit code, refer to https://aka.ms/templating-exit-codes#103 """; - Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput(notFound)); + Assert.False(WinAppSdkTemplates.InterpretTemplateListOutput(notFound, WinAppSdkTemplates.NoTemplatesFoundExitCode)); } [Fact] @@ -317,7 +382,7 @@ Reactor Blank App (Experimental) reactor,reactor-blank [C#] Wind Reactor MVU App (Experimental) reactor-mvu [C#] Windows/WinUI """; - Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(listing)); + Assert.True(WinAppSdkTemplates.InterpretTemplateListOutput(listing, exitCode: 0)); } [Fact] From 03febcf65a0cc25f1b804f6d29672900fa80ef4b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 17:03:08 -0700 Subject: [PATCH 32/37] Restore packaged launch coverage, and fix three migration leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2 on the winapp migration. Packaged launch coverage is back, and this one I had wrong. I had recorded the gap in the PR description as "deliberately deferred — launching a packaged app in CI needs Developer Mode plus identity", but the repo already solves exactly that: ci.yml's Packaged Selftests job enables Developer Mode via AppModelUnlock, because GitHub's windows runners are administrators. So the blocker I assumed was not a blocker. The deleted integration test really did scaffold → `dotnet run` → assert over UIA; it covered the *unpackaged* `reactorapp` template, so it could not survive this migration unchanged, but dropping it left build-only coverage of the change this PR is actually about. The bootstrap job now enables Developer Mode and launches the scaffolded app, asserting the two things a build cannot: the loose-layout package registers (Get-AppxPackage) and the app activates. On failure it dumps the `dotnet run` output rather than just timing out. Also: - `plugins/reactor/agents/reactor-dev.agent.md` is *shipped* in the agent kit and still told agents to `dotnet new install ...::0.0.7-alpha` — a hard-coded version in the one artifact most likely to be followed verbatim, and the one place a stale pin does the most damage. Now points at `winapp new`. - CHANGELOG called `mur templates status` "unchanged". It has never shipped — `mur templates` is new in this release — so it now has an Added entry documenting the exit codes that bootstrap and `mur doctor` branch on. - Restored a `` opening tag lost when I sliced WinAppSdkTemplates.cs down to the probes, which left the doc comment on a public member malformed. Checked the other two sliced files for the same damage; they are balanced. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 64 +++++++++++++++++++ CHANGELOG.md | 9 ++- plugins/reactor/agents/reactor-dev.agent.md | 7 +- .../Templates/WinAppSdkTemplates.cs | 1 + 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 5386b33f8..7b8bf57db 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -268,6 +268,70 @@ jobs: --nologo -v:m if ($LASTEXITCODE -ne 0) { throw "TestApp build exited $LASTEXITCODE" } + # A packaged app can build cleanly and still fail to register or activate, + # and "scaffolded apps are now packaged" is the central behaviour change in + # this migration — so building is not end-to-end coverage. The integration + # test this PR deleted did scaffold→`dotnet run`→UIA against the *unpackaged* + # `reactorapp` template; this is its packaged replacement, asserting the two + # things the build cannot: the loose layout registers, and the app activates. + # + # Registering an unsigned loose MSIX layout requires Developer Mode. GitHub's + # windows runners are administrators, so the AppModelUnlock switch is settable + # directly — same approach as the Packaged Selftests job in ci.yml. + - name: Enable Developer Mode + shell: pwsh + run: | + $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' + New-Item -Path $key -Force | Out-Null + New-ItemProperty -Path $key -Name AllowDevelopmentWithoutDevLicense ` + -PropertyType DWord -Value 1 -Force | Out-Null + $v = (Get-ItemProperty -Path $key -Name AllowDevelopmentWithoutDevLicense).AllowDevelopmentWithoutDevLicense + if ($v -ne 1) { throw "Developer Mode is not enabled (AllowDevelopmentWithoutDevLicense=$v)." } + + - name: Launch TestApp (packaged registration + activation) + shell: pwsh + run: | + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'ARM64' } else { 'x64' } + Push-Location TestProjects/TestApp + try { + # `dotnet run` on a single-project MSIX registers a loose-layout + # package and activates it. It does not return while the app is up, + # so run it as a job and poll. + $job = Start-Job -ScriptBlock { + param($dir, $arch) + Set-Location $dir + dotnet run -c Release "-p:Platform=$arch" --nologo 2>&1 + } -ArgumentList (Get-Location).Path, $arch + + $proc = $null + for ($i = 0; $i -lt 90; $i++) { + Start-Sleep -Seconds 2 + $proc = Get-Process -Name 'TestApp' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($proc) { break } + if ($job.State -eq 'Completed' -or $job.State -eq 'Failed') { break } + } + + $pkg = Get-AppxPackage -Name 'TestApp' -ErrorAction SilentlyContinue + + if (-not $proc) { + Write-Host '--- dotnet run output ---' + Receive-Job $job 2>&1 | Write-Host + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + throw 'TestApp never started — the packaged app built but did not register/activate.' + } + + Write-Host " [ok] TestApp activated (pid $($proc.Id))" + if ($pkg) { Write-Host " [ok] registered package $($pkg.PackageFullName)" } + else { throw 'TestApp is running but no loose-layout package is registered.' } + + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + } finally { + Pop-Location + } + - name: Verify mur upgrade is idempotent shell: pwsh run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index be25d8d37..502e24c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ Conventions for contributors: ### Added +- **`mur templates status`** reports whether `dotnet new reactor` actually resolves, rather than + merely whether the template pack id is registered — an installed-but-too-old pack (`0.0.6-alpha` + shipped before the Reactor templates existed) is a PASS on the package check and a failure on the + next scaffold. Exit codes are load-bearing: `0` available, `1` probe failed, `2` pack installed + but too old, `3` pack not installed. It backs `mur doctor` and `bootstrap.ps1`'s verification + step, which give different remediation for each. + ### Changed ### Deprecated @@ -60,7 +67,7 @@ Conventions for contributors: Windows App SDK CLI's job: `winapp new -t reactor -n MyApp` installs the pack on demand and scaffolds in one step, and `winapp new --list` installs it without scaffolding. `bootstrap.ps1` now drives that command, and `mur upgrade` reports on the templates instead of installing them. - `mur templates status` is unchanged and still backs `mur doctor` and bootstrap's verification. + `mur templates status` (see Added) still backs `mur doctor` and bootstrap's verification. Reactor carried its own installer because `dotnet new install` has no `--prerelease` switch and resolves stable-only, which fails outright while the pack is prerelease-only; working around diff --git a/plugins/reactor/agents/reactor-dev.agent.md b/plugins/reactor/agents/reactor-dev.agent.md index ceb124213..dbb73ce88 100644 --- a/plugins/reactor/agents/reactor-dev.agent.md +++ b/plugins/reactor/agents/reactor-dev.agent.md @@ -18,11 +18,14 @@ user-invocable: true > > Richer starting points: `reactor-mvu` (Model-View-Update via `UseReducer`), `reactor-navview` (`NavigationView` shell), `reactor-tabview` (`TabView` shell). > -> If the templates aren't installed yet, install the pack before scaffolding: +> If the templates aren't installed yet, scaffold with the Windows App SDK CLI +> instead — it installs the pack on demand, so there is no version to pin: > > ``` -> dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates::0.0.7-alpha +> winapp new -t reactor -n > ``` +> +> To install the pack without scaffolding, run `winapp new --list`. You build Reactor apps in this rhythm: scaffold → understand requirements → draft component tree → write files in a batch → `mur check`. diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index f293a9332..3106c3124 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -57,6 +57,7 @@ public static class WinAppSdkTemplates "reactor-tabview", ]; + /// /// True when the template *package* is registered with the `dotnet new` /// engine. Returns null when the installed-package list could not be /// enumerated at all (no `dotnet` on PATH, engine error) so callers can From eb16253779460da8251b45586d13697e9026da9b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 17:52:08 -0700 Subject: [PATCH 33/37] Resolve the packaged app by its real identity, and bound the version scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 3 — both findings are in code from the previous two commits, and the first would have failed CI on its first run. The packaged launch smoke resolved the registered package with `Get-AppxPackage -Name 'TestApp'`. The Reactor manifest's Identity/@Name is a generated GUID, not the project name, so that lookup returns nothing even on a perfectly successful activation and the step throws every time. Verified by scaffolding: the emitted manifest carries `Name="4B99F56F-D9A7-4DDA-8850-ADE3D1D146DE"`. The step now reads the identity out of the scaffolded Package.appxmanifest and looks that up, and prints it so a future failure is diagnosable rather than mysterious. `InterpretInstalledVersionOutput` scanned up to four lines past the package header for a `Version:` line without stopping at the next package. A pack with no version line therefore borrowed its neighbour's — reporting a version that is not installed, which is worse than reporting none, because `mur doctor` prints it in a PASS line. The scan now stops at the first line indented no further than the header. Mutation-checked: removing that guard reddens the new adjacent-package case and nothing else. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 11 ++++++-- .../Templates/WinAppSdkTemplates.cs | 12 ++++++++ .../Reactor.Tests/WinAppSdkTemplatesTests.cs | 28 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 7b8bf57db..465c491e8 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -311,7 +311,14 @@ jobs: if ($job.State -eq 'Completed' -or $job.State -eq 'Failed') { break } } - $pkg = Get-AppxPackage -Name 'TestApp' -ErrorAction SilentlyContinue + # The Reactor manifest's Identity/@Name is a generated GUID, not the + # project name, so `Get-AppxPackage -Name TestApp` finds nothing even + # on a perfectly successful activation. Read the identity the + # template actually emitted. + [xml]$manifest = Get-Content 'Package.appxmanifest' -Raw + $identity = $manifest.Package.Identity.Name + Write-Host " package identity: $identity" + $pkg = Get-AppxPackage -Name $identity -ErrorAction SilentlyContinue if (-not $proc) { Write-Host '--- dotnet run output ---' @@ -323,7 +330,7 @@ jobs: Write-Host " [ok] TestApp activated (pid $($proc.Id))" if ($pkg) { Write-Host " [ok] registered package $($pkg.PackageFullName)" } - else { throw 'TestApp is running but no loose-layout package is registered.' } + else { throw "TestApp is running but no loose-layout package is registered for identity '$identity'." } Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue Stop-Job $job -ErrorAction SilentlyContinue diff --git a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs index 3106c3124..f3394fb4f 100644 --- a/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs +++ b/src/Reactor.Cli/Templates/WinAppSdkTemplates.cs @@ -205,8 +205,18 @@ internal static bool MatchesBlankShortName(string output) if (!lines[i].Trim().Equals(PackageId, StringComparison.OrdinalIgnoreCase)) continue; + // The package id sits at one indent level and its metadata deeper, so + // a line indented no further than the header starts the *next* package. + // Stopping there matters: without it, a package with no `Version:` line + // borrows the next package's version, which is worse than reporting + // nothing — `mur doctor` would name a version that isn't installed. + var headerIndent = IndentOf(lines[i]); + for (var j = i + 1; j < lines.Length && j <= i + 4; j++) { + if (lines[j].Trim().Length == 0) continue; + if (IndentOf(lines[j]) <= headerIndent) break; + var trimmed = lines[j].Trim(); if (trimmed.StartsWith("Version:", StringComparison.OrdinalIgnoreCase)) return trimmed["Version:".Length..].Trim(); @@ -216,6 +226,8 @@ internal static bool MatchesBlankShortName(string output) return null; } + static int IndentOf(string line) => line.Length - line.TrimStart().Length; + static string? RunCapture(params string[] arguments) => RunCaptureWithExit(arguments).Output; /// diff --git a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs index c66b1852f..d1a909f2f 100644 --- a/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs +++ b/tests/Reactor.Tests/WinAppSdkTemplatesTests.cs @@ -85,6 +85,34 @@ public void InterpretInstalledVersionOutput_reads_the_version_for_this_pack() Assert.Equal("0.0.7-alpha", WinAppSdkTemplates.InterpretInstalledVersionOutput(listing)); } + [Fact] + public void InterpretInstalledVersionOutput_does_not_borrow_the_next_packages_version() + { + // A package with no `Version:` line followed closely by another package: + // a bare 4-line look-ahead walks straight into the neighbour and returns + // *its* version. Reporting a version that is not installed is worse than + // reporting none — `mur doctor` would name it in a PASS line. + const string adjacent = """ + Currently installed items: + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + Microsoft.Other.Templates + Version: 9.9.9 + """; + + Assert.Null(WinAppSdkTemplates.InterpretInstalledVersionOutput(adjacent)); + + // Sanity: the same neighbour is still readable in its own right, so the + // guard stops the scan rather than breaking the parser. + const string normal = """ + Currently installed items: + Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + Version: 0.0.7-alpha + Microsoft.Other.Templates + Version: 9.9.9 + """; + Assert.Equal("0.0.7-alpha", WinAppSdkTemplates.InterpretInstalledVersionOutput(normal)); + } + [Fact] public void InterpretInstalledVersionOutput_returns_null_when_this_pack_is_absent() { From bf9e92d6014cc87f57d15624180d7b0c9c1f78a0 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 19:47:12 -0700 Subject: [PATCH 34/37] Assert the scaffolded app actually renders, and fix the packaging goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4. The packaged launch smoke proved a process started and a package registered, but not that the Reactor UI came up. A scaffold with a broken App.cs or package reference can launch into Reactor's render-error fallback and satisfy both — so the replacement was weaker than the `CreateTemplateTests` it replaced, which asserted a real UIA element. The step now asserts one. The blank template renders `TitleBar("$projectname$")`, so an element named TestApp is in the tree exactly when the scaffold rendered; `winapp ui search` (already a CI dependency, used by the E2E job) queries it against the app's window, with a retry loop and a full `ui inspect` dump on failure so a regression is diagnosable rather than a bare timeout. packaging.md.dt's frontmatter goal still described the unpackaged WindowsPackageType=None shape as "what the project template produces". The body had already been corrected; the goal now leads with the packaged single-project MSIX default and names unpackaged as the alternative. It is authoring metadata rather than rendered output, so no generated file changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 21 +++++++++++++++++++++ docs/_pipeline/templates/packaging.md.dt | 14 +++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 465c491e8..bb66d00bd 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -332,6 +332,27 @@ jobs: if ($pkg) { Write-Host " [ok] registered package $($pkg.PackageFullName)" } else { throw "TestApp is running but no loose-layout package is registered for identity '$identity'." } + # A process that starts and a package that registers do not prove the + # Reactor UI rendered — an app can come up in Reactor's render-error + # fallback with a broken App.cs or package reference and still satisfy + # both. The deleted CreateTemplateTests caught that by asserting a UIA + # element; this is the packaged equivalent. The blank template renders + # `TitleBar("$projectname$")`, so a TitleBar named TestApp is present + # exactly when the scaffold rendered. + $rendered = $false + for ($i = 0; $i -lt 20; $i++) { + $hit = winapp ui search 'TestApp' --window $proc.MainWindowHandle --json 2>&1 | Out-String + if ($LASTEXITCODE -eq 0 -and $hit -match 'TestApp') { $rendered = $true; break } + Start-Sleep -Seconds 3 + $proc.Refresh() + } + if (-not $rendered) { + Write-Host '--- UI tree ---' + winapp ui inspect --app TestApp 2>&1 | Write-Host + throw 'TestApp activated but its Reactor UI did not render (no element named TestApp in the tree).' + } + Write-Host ' [ok] Reactor UI rendered (TitleBar present)' + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue Stop-Job $job -ErrorAction SilentlyContinue Remove-Job $job -Force -ErrorAction SilentlyContinue diff --git a/docs/_pipeline/templates/packaging.md.dt b/docs/_pipeline/templates/packaging.md.dt index c36a357f9..5fac5b182 100644 --- a/docs/_pipeline/templates/packaging.md.dt +++ b/docs/_pipeline/templates/packaging.md.dt @@ -4,13 +4,13 @@ app: packaging order: 2.8 audience: intermediate goal: | - How to ship a Reactor app: the unpackaged WindowsPackageType=None - shape that the project template produces, MSIX for Store / - sideloading, single-file publish, ARM64 as a second runtime - identifier, and what does and doesn't work under Native AOT given - Reactor's reflection-heavy DataGrid / devtools paths. Solid tier — - CSPROJ-driven snippets pulled from real samples + the - `dotnet new reactor` templates. + How to ship a Reactor app: the packaged single-project MSIX shape + that `dotnet new reactor` produces by default, the unpackaged + WindowsPackageType=None alternative, single-file publish, ARM64 as a + second runtime identifier, and what does and doesn't work under + Native AOT given Reactor's reflection-heavy DataGrid / devtools + paths. Solid tier — CSPROJ-driven snippets pulled from real samples + + the `dotnet new reactor` templates. tier: solid --- From 713984a89bbc34e5ad08c28824c47e1ee044d60c Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 20:12:52 -0700 Subject: [PATCH 35/37] Match the template pack id by line in the bootstrap CI guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 5. The workflow's "pack is installed" assertion was a substring match over the whole `dotnet new uninstall` transcript, so a longer id that merely contains ours — `...CSharp.Templates.Extras` — satisfied it while the required pack was absent. That is the same prefix false-positive `WinAppSdkTemplates.InterpretPackageInstalledOutput` already guards against in the CLI; the CI mirror had not been updated to match. Both assertions now compare trimmed whole lines. Verified in both directions rather than by inspection: against a transcript containing only the `.Extras` pack the old check reports installed=True and the new one False, and against this machine's real listing the new check still reports True. Rewriting the positive assertion left `$packages` undefined at the negative one, which would have made the "the removed ProjectTemplates pack must not be installed" check silently vacuous — a guard that always passes. Both now read from the same `$packageLines`. Also reconciled the PR description, which still claimed the scaffold→launch smoke was deliberately not replaced. It is replaced, and strengthened: the description now documents the registration/activation/render assertions and quotes the CI output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index bb66d00bd..a1144cb6a 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -202,16 +202,20 @@ jobs: } Write-Host " [ok] $shortName" } - # The pack itself must be the Windows App SDK one. - $packages = dotnet new uninstall 2>&1 | Out-String - if ($packages -notmatch 'Microsoft\.WindowsAppSDK\.WinUI\.CSharp\.Templates') { + # The pack itself must be the Windows App SDK one. Match the id as a + # whole line, not a substring: a longer id that merely contains ours + # (`...CSharp.Templates.Extras`) would otherwise satisfy this while the + # required pack is absent — the same prefix false-positive + # WinAppSdkTemplates.InterpretPackageInstalledOutput guards against. + $packageLines = (dotnet new uninstall 2>&1 | Out-String) -split "`r?`n" | ForEach-Object { $_.Trim() } + if ($packageLines -notcontains 'Microsoft.WindowsAppSDK.WinUI.CSharp.Templates') { throw "Microsoft.WindowsAppSDK.WinUI.CSharp.Templates is not installed" } Write-Host " [ok] Microsoft.WindowsAppSDK.WinUI.CSharp.Templates registered" # The in-repo Microsoft.UI.Reactor.ProjectTemplates pack was deleted. # Nothing may resurrect it: a stray install would hand new developers # the unpackaged `reactorapp` template the docs no longer describe. - if ($packages -match 'Microsoft\.UI\.Reactor\.ProjectTemplates') { + if ($packageLines -contains 'Microsoft.UI.Reactor.ProjectTemplates') { throw "the removed Microsoft.UI.Reactor.ProjectTemplates pack is installed; it should no longer exist" } Write-Host " [ok] removed Microsoft.UI.Reactor.ProjectTemplates not installed" From 580b78985f57e51b299510f4bcb75ce42c098302 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 20:21:30 -0700 Subject: [PATCH 36/37] Resolve winapp in the CI UI assertion too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 6 — the same alias-path bug it caught in bootstrap.ps1, which I then reintroduced in the CI step added two commits later. winget's MSIX install drops an app-execution alias that exists on disk without always being resolvable through the current process's PATH; bootstrap's Get-WinAppCliPath handles that, and the workflow does not add the alias directory to PATH afterwards. So the rendered-UI assertion could fail on a runner with exactly the installation state the fallback exists to support — reported as "the Reactor UI did not render" rather than "winapp was not found", which is the misdiagnosis that costs the most time. The step now resolves the concrete path the same way and invokes it, and fails with an explicit "winapp CLI not found" if neither location has it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index a1144cb6a..24e127fff 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -343,16 +343,27 @@ jobs: # element; this is the packaged equivalent. The blank template renders # `TitleBar("$projectname$")`, so a TitleBar named TestApp is present # exactly when the scaffold rendered. + # Resolve winapp the same way bootstrap's Get-WinAppCliPath does: + # winget's MSIX install drops an app-execution alias that exists on + # disk without always being resolvable through this process's PATH, + # so a bare `winapp` can fail on exactly that installation state. + $winApp = (Get-Command winapp -ErrorAction SilentlyContinue).Source + if (-not $winApp) { + $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\winapp.exe' + if (Test-Path $alias) { $winApp = $alias } + } + if (-not $winApp) { throw 'winapp CLI not found, so the rendered-UI assertion cannot run.' } + $rendered = $false for ($i = 0; $i -lt 20; $i++) { - $hit = winapp ui search 'TestApp' --window $proc.MainWindowHandle --json 2>&1 | Out-String + $hit = & $winApp ui search 'TestApp' --window $proc.MainWindowHandle --json 2>&1 | Out-String if ($LASTEXITCODE -eq 0 -and $hit -match 'TestApp') { $rendered = $true; break } Start-Sleep -Seconds 3 $proc.Refresh() } if (-not $rendered) { Write-Host '--- UI tree ---' - winapp ui inspect --app TestApp 2>&1 | Write-Host + & $winApp ui inspect --app TestApp 2>&1 | Write-Host throw 'TestApp activated but its Reactor UI did not render (no element named TestApp in the tree).' } Write-Host ' [ok] Reactor UI rendered (TitleBar present)' From 45e030267656205d8b838b275e7cfc8d1259d349 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 20:30:15 -0700 Subject: [PATCH 37/37] Tear down the smoke app on every failure path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 7. The launch smoke's cleanup sat on the success path, so any throw between starting the job and the final assertion left a live packaged TestApp and a blocked `dotnet run` job behind. That is worse than a leak: the steps that follow are the `mur upgrade` and bootstrap idempotence checks, which a still-registered, still-running smoke app can contaminate — and the orphaned job can leave the runner waiting on it. Process and job teardown moved into `finally`, guarded so it is safe when the failure happened before either was created. Verified rather than assumed, with a PID-specific oracle: a probe that throws before the old cleanup point leaves no surviving process. (The first probe used `notepad`, which was already running on this machine — an oracle that cannot tell a leak from a pre-existing process, so it was replaced.) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b893b2b8-78f8-46c6-9227-515e1dc3a563 --- .github/workflows/bootstrap.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 24e127fff..76b6e7e00 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -296,6 +296,8 @@ jobs: shell: pwsh run: | $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'ARM64' } else { 'x64' } + $job = $null + $proc = $null Push-Location TestProjects/TestApp try { # `dotnet run` on a single-project MSIX registers a loose-layout @@ -307,7 +309,6 @@ jobs: dotnet run -c Release "-p:Platform=$arch" --nologo 2>&1 } -ArgumentList (Get-Location).Path, $arch - $proc = $null for ($i = 0; $i -lt 90; $i++) { Start-Sleep -Seconds 2 $proc = Get-Process -Name 'TestApp' -ErrorAction SilentlyContinue | Select-Object -First 1 @@ -327,8 +328,6 @@ jobs: if (-not $proc) { Write-Host '--- dotnet run output ---' Receive-Job $job 2>&1 | Write-Host - Stop-Job $job -ErrorAction SilentlyContinue - Remove-Job $job -Force -ErrorAction SilentlyContinue throw 'TestApp never started — the packaged app built but did not register/activate.' } @@ -367,11 +366,16 @@ jobs: throw 'TestApp activated but its Reactor UI did not render (no element named TestApp in the tree).' } Write-Host ' [ok] Reactor UI rendered (TitleBar present)' - - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue - Stop-Job $job -ErrorAction SilentlyContinue - Remove-Job $job -Force -ErrorAction SilentlyContinue } finally { + # Every throw above leaves a live packaged app and a blocked + # `dotnet run` job behind. Tear them down here, not on the success + # path only: a leaked TestApp contaminates the idempotence steps that + # follow, and a leaked job can leave the runner waiting on it. + if ($proc) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + if ($job) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + } Pop-Location }