diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml
new file mode 100644
index 000000000..effd06403
--- /dev/null
+++ b/.github/workflows/bootstrap.yml
@@ -0,0 +1,194 @@
+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
+# `-InstallWinAppSdk -SkipPlugin`, which also exercises the winget install
+# path for the Windows App Runtime when the runner image doesn't already
+# have it.
+
+on:
+ pull_request:
+ paths:
+ - 'bootstrap.ps1'
+ - 'src/Reactor.Cli/**'
+ - 'tools/Templates/**'
+ - 'Directory.Build.props'
+ - '.github/workflows/bootstrap.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'bootstrap.ps1'
+ - 'src/Reactor.Cli/**'
+ - 'tools/Templates/**'
+ - 'Directory.Build.props'
+ - '.github/workflows/bootstrap.yml'
+ workflow_dispatch:
+
+# Cancel in-progress runs against the same ref when a new push arrives.
+concurrency:
+ group: bootstrap-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ bootstrap:
+ name: bootstrap.ps1 (windows-latest)
+ runs-on: windows-latest
+ timeout-minutes: 35
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ # NOTE: deliberately *not* running actions/setup-dotnet here. We want
+ # bootstrap.ps1 to detect the SDK situation on the runner and, if
+ # necessary, winget-install .NET 10 itself. If the runner image
+ # already has 10.x the install branch is a silent no-op and we
+ # still exercise the rest of the pipeline.
+
+ - name: Pre-flight — show toolchain baseline
+ shell: pwsh
+ run: |
+ # Diagnostic-only step. Reset $LASTEXITCODE on exit so a non-zero
+ # winget status (which is common — "not installed" returns
+ # -1978335166) doesn't fail the workflow before bootstrap.ps1 even
+ # starts.
+ Write-Host "=== dotnet --list-sdks ==="
+ if (Get-Command dotnet -ErrorAction SilentlyContinue) {
+ dotnet --list-sdks
+ } else {
+ Write-Host "(dotnet not on PATH)"
+ }
+ Write-Host ""
+ Write-Host "=== winget --version ==="
+ winget --version
+ Write-Host ""
+ Write-Host "=== Windows App Runtime 2.0 (winget list) ==="
+ # --accept-source-agreements: a fresh runner hasn't accepted the
+ # msstore source terms. Without it, winget prompts and fails on a
+ # non-interactive shell.
+ winget list --id Microsoft.WindowsAppRuntime.2.0 --exact --accept-source-agreements 2>&1
+ Write-Host "exit=$LASTEXITCODE"
+ $global:LASTEXITCODE = 0
+
+ - name: Run bootstrap.ps1 (non-interactive)
+ shell: pwsh
+ run: |
+ ./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin
+ if ($LASTEXITCODE -ne 0) { throw "bootstrap.ps1 exited $LASTEXITCODE" }
+
+ - name: Verify mur resolves from a fresh shell
+ shell: pwsh
+ run: |
+ # A new step gets a fresh process with its own $env:Path snapshot.
+ # `dotnet tool install -g` updates the *User* PATH, which fresh
+ # processes pick up at start. If GH Actions doesn't propagate that
+ # (varies by image), prepend defensively.
+ $toolsDir = Join-Path $env:USERPROFILE '.dotnet\tools'
+ if (-not (($env:Path -split ';') -contains $toolsDir)) {
+ $env:Path = "$toolsDir;$env:Path"
+ }
+ $cmd = Get-Command mur -ErrorAction SilentlyContinue
+ if (-not $cmd) { throw "mur is not resolvable on PATH after bootstrap" }
+ Write-Host "mur resolved at: $($cmd.Source)"
+ mur --version
+ if ($LASTEXITCODE -ne 0) { throw "mur --version exited $LASTEXITCODE" }
+
+ - name: Run mur doctor (must report all checks PASS / info-only)
+ shell: pwsh
+ run: |
+ $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path"
+ mur doctor
+ if ($LASTEXITCODE -ne 0) { throw "mur doctor failed (exit $LASTEXITCODE)" }
+
+ - name: Verify local-nupkgs/ produced
+ shell: pwsh
+ 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 = @()
+ foreach ($p in $expected) {
+ if (Test-Path $p) {
+ $size = (Get-Item $p).Length
+ Write-Host " [ok] $p ($size bytes)"
+ } else {
+ Write-Host " [missing] $p" -ForegroundColor Red
+ # Microsoft.UI.Reactor.Cli is versioned from MinVer/etc and
+ # may not match the literal "1.0.0" filename. Don't fail on
+ # it specifically — just on the two 0.0.0-local nupkgs.
+ if ($p -notlike '*Microsoft.UI.Reactor.Cli*') { $missing += $p }
+ }
+ }
+ if ($missing.Count -gt 0) { throw "Missing nupkgs: $($missing -join ', ')" }
+ # Sanity: at least one *.Cli.*.nupkg under local-nupkgs/.
+ $cliPkgs = @(Get-ChildItem local-nupkgs -Filter 'Microsoft.UI.Reactor.Cli.*.nupkg')
+ 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
+ shell: pwsh
+ run: |
+ $listing = dotnet new list reactorapp 2>&1
+ $rc = $LASTEXITCODE
+ Write-Host $listing
+ if ($rc -ne 0) { throw "dotnet new list reactorapp 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
+ # 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"
+ }
+
+ - name: Scaffold a TestApp and restore against the local feed
+ shell: pwsh
+ run: |
+ # 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
+ Push-Location TestProjects
+ try {
+ dotnet new reactorapp -n TestApp
+ if ($LASTEXITCODE -ne 0) { throw "dotnet new reactorapp exited $LASTEXITCODE" }
+ if (-not (Test-Path 'TestApp/TestApp.csproj')) { throw "TestApp/TestApp.csproj not produced" }
+
+ dotnet restore TestApp/TestApp.csproj --nologo -v:m
+ if ($LASTEXITCODE -ne 0) { throw "dotnet restore for TestApp exited $LASTEXITCODE" }
+ } finally {
+ Pop-Location
+ }
+
+ - name: Build TestApp (default WindowsAppSDKSelfContained=true)
+ shell: pwsh
+ run: |
+ # WindowsAppSDKSelfContained=true (inherited from
+ # Directory.Build.props) requires a concrete arch to embed the
+ # runtime under — AnyCPU is rejected by the SelfContained
+ # target. Pass the host arch explicitly.
+ $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'ARM64' } else { 'x64' }
+ dotnet build TestProjects/TestApp/TestApp.csproj `
+ -c Release `
+ "-p:Platform=$arch" `
+ --nologo -v:m
+ if ($LASTEXITCODE -ne 0) { throw "TestApp build exited $LASTEXITCODE" }
+
+ - name: Verify mur upgrade is idempotent
+ shell: pwsh
+ run: |
+ # `mur upgrade` should succeed against an already-bootstrapped tree:
+ # re-pack, re-install template (uninstall-first), refresh plugin.
+ $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path"
+ mur upgrade --skip-plugin
+ if ($LASTEXITCODE -ne 0) { throw "mur upgrade exited $LASTEXITCODE" }
+
+ - name: Re-run bootstrap.ps1 (idempotence check)
+ shell: pwsh
+ run: |
+ # Re-running on the same machine should be a clean no-op for any
+ # already-correct piece (winget detects already-installed runtimes,
+ # dotnet tool update is a no-op when up to date, etc.).
+ ./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin
+ if ($LASTEXITCODE -ne 0) { throw "bootstrap.ps1 re-run exited $LASTEXITCODE" }
diff --git a/bootstrap.ps1 b/bootstrap.ps1
index 7031bca3d..7861e2c37 100644
--- a/bootstrap.ps1
+++ b/bootstrap.ps1
@@ -22,21 +22,45 @@
.PARAMETER Configuration
Build configuration for the CLI nupkg. Default: Release.
+.PARAMETER InstallWinAppSdk
+ Install the Windows App Runtime 2.0 via winget without prompting.
+ Useful for CI / one-shot dev-box automation. Mutually exclusive with
+ -NoWinAppSdk. The framework defaults to self-contained, so the
+ runtime is only required for framework-dependent deployment.
+
+.PARAMETER NoWinAppSdk
+ Skip the Windows App Runtime 2.0 prompt silently. Useful for
+ non-interactive scripts that explicitly don't want the runtime
+ installed. Mutually exclusive with -InstallWinAppSdk.
+
.EXAMPLE
./bootstrap.ps1
- Full bootstrap.
+ Full bootstrap (prompts before installing WindowsAppRuntime).
.EXAMPLE
./bootstrap.ps1 -SkipPlugin
Skip the Claude plugin step.
+
+.EXAMPLE
+ ./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin
+ Non-interactive: install everything (incl. WindowsAppRuntime) and
+ skip the agent plugin. Suitable for CI / fresh-dev-box automation.
#>
[CmdletBinding()]
param(
[switch]$SkipPlugin,
[switch]$SkipMurInstall,
- [string]$Configuration = 'Release'
+ [string]$Configuration = 'Release',
+ [switch]$InstallWinAppSdk,
+ [switch]$NoWinAppSdk
)
+if ($InstallWinAppSdk -and $NoWinAppSdk) {
+ Write-Host ''
+ Write-Host "ERROR: -InstallWinAppSdk and -NoWinAppSdk are mutually exclusive." -ForegroundColor Red
+ exit 1
+}
+
$ErrorActionPreference = 'Stop'
$repoRoot = $PSScriptRoot
Set-Location $repoRoot
@@ -56,33 +80,109 @@ function Fail($msg) {
exit 1
}
+# Install a winget package and refresh $env:Path so the freshly-installed tool
+# is resolvable in this same shell. Hard-fails if winget itself is missing —
+# that's an OS-level prerequisite this script doesn't try to repair.
+function Install-WithWinget {
+ param(
+ [Parameter(Mandatory)][string]$Id,
+ [string]$Reason = $Id
+ )
+ if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
+ Fail "Need to install '$Reason' but winget is not on PATH. Install App Installer from the Microsoft Store, then re-run ./bootstrap.ps1."
+ }
+ Write-Host " Installing $Reason via winget ($Id)..." -ForegroundColor Yellow
+ & winget install --id $Id --accept-source-agreements --accept-package-agreements --silent --disable-interactivity
+ if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne -1978335189) {
+ # -1978335189 = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE (already installed / up-to-date)
+ Fail "winget install $Id failed (exit $LASTEXITCODE). Install $Reason manually and re-run ./bootstrap.ps1."
+ }
+ # winget edits the Machine + User PATH but the current process keeps its
+ # original. Rebuild $env:Path from the registry so subsequent commands in
+ # this script can find the freshly-installed binaries.
+ $env:Path = (
+ [Environment]::GetEnvironmentVariable('Path', 'Machine'),
+ [Environment]::GetEnvironmentVariable('Path', 'User')
+ ) -join ';'
+}
+
# ---------------------------------------------------------------------------
# 1. Pre-flight
# ---------------------------------------------------------------------------
Write-Step 'Pre-flight checks'
-$dotnetCmd = Get-Command dotnet -ErrorAction SilentlyContinue
-if (-not $dotnetCmd) {
- winget install Microsoft.DotNet.SDK.10
- $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
-}
-$sdkOutput = & dotnet --list-sdks
-$has10OrLater = $false
-foreach ($line in $sdkOutput) {
- if ($line -match '^(\d+)\.') {
- if ([int]$Matches[1] -ge 10) { $has10OrLater = $true; break }
+function Test-DotnetSdk10 {
+ if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { return $false }
+ foreach ($line in (& dotnet --list-sdks)) {
+ if ($line -match '^(\d+)\.' -and [int]$Matches[1] -ge 10) { return $true }
}
+ return $false
}
-if (-not $has10OrLater) {
- Fail @"
-.NET 10+ SDK not detected — Reactor requires 10 or later.
-Installed SDKs:
-$($sdkOutput -join "`n")
-Install the latest .NET SDK from https://dotnet.microsoft.com/download and re-run ./bootstrap.ps1.
-"@
+
+if (-not (Test-DotnetSdk10)) {
+ if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
+ Write-Host " [info] dotnet not found on PATH." -ForegroundColor Yellow
+ } else {
+ Write-Host " [info] dotnet present but no .NET 10+ SDK detected. Installed:" -ForegroundColor Yellow
+ & dotnet --list-sdks | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
+ }
+ Install-WithWinget -Id 'Microsoft.DotNet.SDK.10' -Reason '.NET 10 SDK'
+ if (-not (Test-DotnetSdk10)) {
+ Fail '.NET 10 SDK install reported success but `dotnet --list-sdks` still does not show a 10.x entry. Open a new shell and re-run ./bootstrap.ps1.'
+ }
}
Write-Ok ".NET SDK present"
+# Windows App SDK runtime — optional but recommended.
+#
+# The framework defaults to WindowsAppSDKSelfContained=true (see
+# Directory.Build.props), so builds and scaffolded apps work *without* the
+# machine-wide runtime — every app's bin/ output ships its own copy of WAS
+# native binaries from NuGet restore.
+#
+# But many devs prefer framework-dependent deployment: smaller per-app
+# output, faster incremental builds, and the runtime installed once on the
+# machine. For that path the user needs the WindowsAppRuntime 2.0 install
+# matching our WindowsAppSDKVersion=2.0.1.
+#
+# So we prompt by default. `-InstallWinAppSdk` to force-install,
+# `-InstallWinAppSdk:$false` to skip the prompt non-interactively.
+
+function Test-WindowsAppRuntime20 {
+ if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return $true } # nothing we can check without winget
+ # --accept-source-agreements is needed even for `list` on a winget that
+ # hasn't been used before (e.g. a fresh CI runner). Without it, winget
+ # prompts for msstore terms and fails on a non-interactive shell with
+ # exit -1978335166.
+ & winget list --id Microsoft.WindowsAppRuntime.2.0 --exact --accept-source-agreements 2>$null | Out-Null
+ $rc = $LASTEXITCODE
+ $global:LASTEXITCODE = 0 # don't let winget's status leak out of the probe
+ return ($rc -eq 0)
+}
+
+if (-not (Test-WindowsAppRuntime20)) {
+ if ($InstallWinAppSdk) {
+ Install-WithWinget -Id 'Microsoft.WindowsAppRuntime.2.0' -Reason 'Windows App Runtime 2.0'
+ } elseif ($NoWinAppSdk) {
+ Write-Host ' [skip] Windows App Runtime 2.0 not installed (skipped per -NoWinAppSdk).' -ForegroundColor Yellow
+ } else {
+ Write-Host ''
+ Write-Host ' Windows App Runtime 2.0 is not installed on this machine.' -ForegroundColor Yellow
+ Write-Host ' Reactor builds default to WindowsAppSDKSelfContained=true, so this is optional —'
+ Write-Host ' your apps will work either way. Installing it enables framework-dependent'
+ Write-Host ' deployment (smaller per-app output, faster builds) when you override'
+ Write-Host ' WindowsAppSDKSelfContained=false in a consuming project.'
+ $answer = Read-Host ' Install Windows App Runtime 2.0 via winget now? [y/N]'
+ if ($answer -match '^[Yy]') {
+ Install-WithWinget -Id 'Microsoft.WindowsAppRuntime.2.0' -Reason 'Windows App Runtime 2.0'
+ } else {
+ Write-Host " Skipped. Re-run later with: winget install Microsoft.WindowsAppRuntime.2.0" -ForegroundColor Cyan
+ }
+ }
+} else {
+ Write-Ok 'Windows App Runtime 2.0 installed'
+}
+
# ---------------------------------------------------------------------------
# 2. Pack `mur` as a global-tool nupkg
# ---------------------------------------------------------------------------
diff --git a/src/Reactor/Reactor.csproj b/src/Reactor/Reactor.csproj
index 1636578b1..ab0b5ce60 100644
--- a/src/Reactor/Reactor.csproj
+++ b/src/Reactor/Reactor.csproj
@@ -102,10 +102,24 @@
against the framework itself), so their build outputs must already
exist before pack — verified by the AssertAnalyzerDllsExist target
below. -->
+
+
+ <_ReactorAnalyzerBinDir Condition="'$(Platform)' == '' or '$(Platform)' == 'AnyCPU'">bin\$(Configuration)\netstandard2.0
+ <_ReactorAnalyzerBinDir Condition="'$(_ReactorAnalyzerBinDir)' == ''">bin\$(Platform)\$(Configuration)\netstandard2.0
+
+
-
-