<# .SYNOPSIS Stage a lean per-site installer bundle on Windows, and verify its third-party payload against bundle-lock.json. .DESCRIPTION The Windows equivalent of build-installer.sh, for a work PC with no Bash. It does the whole job natively - it does NOT shell out to build-site.sh - so the only tools needed are Python, Node and (to compile) Inno Setup. What it does, in order: 1. Resolves the site's plugin closure from its profile. 2. Builds the SPA twice, because Vite compiles the base path in and it therefore cannot be chosen at install time: once for / (subpath deployment) and once for / (its own site). 3. Stages the backend tree: core, the chosen plugins only, and the runtime files a deployable tree needs. 4. Writes plugins.iss so the wizard's plugin page matches the payload. 5. Copies the installer scripts from THIS directory. 6. Verifies wheels\, python\ and the MSIs against bundle-lock.json, and FAILS if the bundle is not exactly what the lock describes. Step 6 is the point of the script. A bundle that does not match its lock is not shipped, and "the wheelhouse was missing" is caught here rather than by an operator halfway through installing on a server with no network. .PARAMETER Profile Path to the site profile (see deploy\site-profile.example.json). .PARAMETER RepoRoot The repository. Defaults to four levels up from this script, which is correct for a normal checkout. .PARAMETER SkipFrontend Reuse the SPA builds already staged in the bundle. For iterating on the installer itself, where two Vite builds per run is most of the wall clock. Never use it for a bundle you intend to ship. .PARAMETER AllowUnlocked Stage the bundle and report payload problems WITHOUT failing. For assembling a bundle before its lock exists. A bundle built this way must not be shipped; run refresh-bundle-lock.ps1, commit the lock, then build again without this. .EXAMPLE .\build-installer.ps1 -Profile ..\..\site-profile.example.json .\build-installer.ps1 -Profile C:\sites\wjf.json -SkipFrontend #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $Profile, [string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path, [string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'), [string] $SubpathAlias = 'shopdb', [switch] $SkipFrontend, [switch] $AllowUnlocked ) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'bundle-lock.ps1') function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c } function Step { param($m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan } function Die { param($m, $fix = '') Write-Host ''; Write-Host " $m" -ForegroundColor Red if ($fix) { Write-Host " $fix" -ForegroundColor Yellow }; exit 1 } function Invoke-Tool { # Runs a build tool and stops on a non-zero exit. npm writes progress to # stderr on SUCCESS, so stderr alone must never be treated as failure. param([string] $Exe, [string[]] $Arguments, [string] $WorkDir, [string] $What) Push-Location $WorkDir try { $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' & $Exe @Arguments 2>&1 | ForEach-Object { Say " $_" 'DarkGray' } $code = $LASTEXITCODE $ErrorActionPreference = $prev if ($code -ne 0) { Die "$What failed (exit $code)" } } finally { Pop-Location } } if (-not (Test-Path $Profile)) { Die "profile not found: $Profile" } if (-not (Test-Path $RepoRoot)) { Die "repo not found: $RepoRoot" } $Profile = (Resolve-Path $Profile).Path $RepoRoot = (Resolve-Path $RepoRoot).Path $AppOut = Join-Path $BundleRoot 'app' $python = (Get-Command python -ErrorAction SilentlyContinue) if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue } if (-not $python) { Die 'Python not found on PATH' 'Install Python 3.14 and re-open the shell.' } $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue if (-not $npm -and -not $SkipFrontend) { Die 'npm not found on PATH' 'Install Node, or pass -SkipFrontend to reuse the staged SPA.' } Say '' Say " repo : $RepoRoot" Say " profile : $Profile" Say " bundle : $BundleRoot" # --- 1. plugin closure ------------------------------------------------------ # Same resolver the Linux builder uses, so both produce the same set from one # profile instead of two implementations of the closure rules. Step 'Resolving plugin closure' $closure = (& $python.Source (Join-Path $RepoRoot 'scripts\resolve_plugin_closure.py') $Profile $RepoRoot) if ($LASTEXITCODE -ne 0 -or -not $closure) { Die 'could not resolve the plugin closure from the profile' } $closure = $closure.Trim() Say " $closure" 'White' # --- 2. frontend ------------------------------------------------------------ $frontend = Join-Path $RepoRoot 'frontend' $subStaged = Join-Path $BundleRoot 'spa-subpath' $rootStaged= Join-Path $BundleRoot 'spa-root' if ($SkipFrontend) { if (-not (Test-Path $subStaged) -or -not (Test-Path $rootStaged)) { Die '-SkipFrontend was passed but no SPA build is staged' 'Run once without it.' } Say '' Say ' Reusing the staged SPA builds (-SkipFrontend). NOT shippable if the frontend changed.' 'Yellow' } else { Step "Building the SPA for /$SubpathAlias/" $env:SITE_PLUGINS = $closure $env:VITE_BASE_PATH = "/$SubpathAlias/" try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'subpath frontend build' } finally { Remove-Item Env:\VITE_BASE_PATH -ErrorAction SilentlyContinue } Remove-Item $subStaged -Recurse -Force -ErrorAction SilentlyContinue Copy-Item (Join-Path $frontend 'dist') $subStaged -Recurse -Force # The alias is fixed at BUILD time; the installer reads this and refuses to # publish under a different one rather than serving a page that cannot load # its own assets. Set-Content -Path (Join-Path $subStaged '.alias') -Value $SubpathAlias -Encoding ASCII # Root build LAST, so frontend\dist is left in the state a developer expects. Step 'Building the SPA for /' try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'root frontend build' } finally { Remove-Item Env:\SITE_PLUGINS -ErrorAction SilentlyContinue } Remove-Item $rootStaged -Recurse -Force -ErrorAction SilentlyContinue Copy-Item (Join-Path $frontend 'dist') $rootStaged -Recurse -Force } # --- 3. backend tree -------------------------------------------------------- Step "Staging the application tree" Remove-Item $AppOut -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path (Join-Path $AppOut 'plugins') -Force | Out-Null Copy-Item (Join-Path $RepoRoot 'shopdb') $AppOut -Recurse -Force foreach ($name in $closure.Split(',')) { $src = Join-Path $RepoRoot ('plugins\' + $name.Trim()) if (-not (Test-Path $src)) { Die "plugin in the closure is not on disk: $name" } Copy-Item $src (Join-Path $AppOut 'plugins') -Recurse -Force } # Runtime files a deployable tree needs beyond the Python packages. Without # these the tree imports but cannot be run or migrated. foreach ($f in @('wsgi.py', 'requirements.txt')) { Copy-Item (Join-Path $RepoRoot $f) $AppOut -Force } Copy-Item (Join-Path $RepoRoot 'migrations') $AppOut -Recurse -Force # ONLY web.config, not all of deploy\. The bundle is staged INSIDE deploy\, so # copying the whole tree would recurse into its own output, and the rest of # deploy\ is installer source that has no business on an application server. # shopdb-install.ps1 reads it from exactly this path. $cfgSrc = Join-Path $RepoRoot 'deploy\windows\web.config' if (Test-Path $cfgSrc) { New-Item -ItemType Directory -Path (Join-Path $AppOut 'deploy\windows') -Force | Out-Null Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force } # A CycloneDX SBOM of everything this tree depends on, Python and npm together. # Staged INTO the tree so it installs onto the server with the application: an # air-gapped site cannot be scanned remotely, so the only way to answer "are we # exposed to this CVE, and where" is for the answer to be sitting on the box. Step 'Generating SBOM' & $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot ` -o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' } if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' } # Docs the running site serves, plus the runbooks an air-gapped server has no # other way to reach. Without openapi.json and llms.txt the self-hosted /api/docs # page is broken on every installed server. Step 'Staging docs' $docsOut = Join-Path $AppOut 'docs' New-Item -ItemType Directory -Path $docsOut -Force | Out-Null foreach ($doc in @('openapi.json', 'llms.txt', 'api-inventory.json', 'INSTALL-WINDOWS.md', 'OPERATE-WINDOWS.md', 'BACKUP-RESTORE.md', 'UPGRADE.md')) { $src = Join-Path $RepoRoot ('docs\' + $doc) if (Test-Path $src) { Copy-Item $src $docsOut -Force; Say " $doc" } } # Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning # reads the same profile the tree was staged from, so the installed plugin set # and the shipped plugin code cannot drift. Copy-Item $Profile (Join-Path $AppOut 'site-profile.json') -Force # The installer expects frontend\dist and frontend\dist-subpath. $feOut = Join-Path $AppOut 'frontend' New-Item -ItemType Directory -Path $feOut -Force | Out-Null Copy-Item $rootStaged (Join-Path $feOut 'dist') -Recurse -Force Copy-Item $subStaged (Join-Path $feOut 'dist-subpath') -Recurse -Force Get-ChildItem $AppOut -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem $AppOut -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue # --- 4. plugins.iss --------------------------------------------------------- Step 'Writing plugins.iss' $shipped = (Get-ChildItem (Join-Path $AppOut 'plugins') -Directory | Select-Object -ExpandProperty Name) -join ',' $aliasBuilt = '' $aliasFile = Join-Path $feOut 'dist-subpath\.alias' if (Test-Path $aliasFile) { $aliasBuilt = (Get-Content $aliasFile -TotalCount 1).Trim() } @" ; GENERATED by build-installer.ps1 - do not edit. ; The plugins present in bundle\app\plugins at build time. #define AvailablePlugins "$shipped" ; The alias the subpath SPA was built for, or empty if this bundle has no ; subpath build - in which case the wizard must not offer that option. #define SubpathAlias "$aliasBuilt" "@ | Set-Content -Path (Join-Path $PSScriptRoot 'plugins.iss') -Encoding ASCII Say " $shipped" 'White' # The product version, read from the code rather than restated in the .iss. Step 'Writing version.iss' $initText = Get-Content (Join-Path $RepoRoot 'shopdb\__init__.py') -Raw if ($initText -notmatch "(?m)^__version__\s*=\s*'([^']+)'") { Die 'could not read __version__ from shopdb\__init__.py' } $appVersion = $Matches[1] @" ; GENERATED by build-installer.ps1 from shopdb/__init__.py - do not edit. #define AppVersion "$appVersion" "@ | Set-Content -Path (Join-Path $PSScriptRoot 'version.iss') -Encoding ASCII Say " $appVersion" 'White' # --- 5. installer scripts --------------------------------------------------- # From THIS directory, which is the reviewed copy under version control. They # used to be copied from a downloads folder, so the logic that shipped was not # the logic that was committed and the build only worked on one machine. Step 'Copying installer scripts' foreach ($f in @('shopdb-install.ps1', 'shopdb-preflight.ps1', 'bundle-lock.ps1')) { $src = Join-Path $PSScriptRoot $f if (-not (Test-Path $src)) { Die "installer script missing from the repo: $f" } Copy-Item $src $BundleRoot -Force Say " $f" } # --- 6. payload verification ------------------------------------------------ Step 'Verifying the third-party payload against bundle-lock.json' $lockPath = Join-Path $PSScriptRoot 'bundle-lock.json' $lock = Read-BundleLock $lockPath if (-not $lock) { $msg = "no bundle-lock.json at $lockPath" if ($AllowUnlocked) { Say " $msg - continuing because -AllowUnlocked was passed" 'Yellow' } else { Die $msg @' Add the wheels and installers to the bundle, then: .\refresh-bundle-lock.ps1 (review the list) .\refresh-bundle-lock.ps1 -Yes (write it) and commit bundle-lock.json. Pass -AllowUnlocked to stage a bundle without one - it must not be shipped. '@ } } else { $problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock if ($problems.Count -eq 0) { Say (" payload matches the lock ({0}, {1})" -f ` (Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'Green' # Ships WITH the bundle: the installer re-checks the payload on the # target server before running any of it, so tampering between build and # install is caught too. Copy-Item $lockPath $BundleRoot -Force } else { Say '' foreach ($p in $problems) { Say " $p" 'Red' } Say '' if ($AllowUnlocked) { Say ' -AllowUnlocked: continuing anyway. DO NOT SHIP this bundle.' 'Yellow' } else { Die ("{0} payload problem(s) - the bundle is not what the lock describes" -f $problems.Count) @' Either the payload is wrong (fix the bundle) or it changed on purpose (run refresh-bundle-lock.ps1, read the diff, and commit the new lock). '@ } } } # --- summary ---------------------------------------------------------------- Write-Host '' Say " Bundle staged at: $BundleRoot" 'White' foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'vcredist', 'mysqlclient', 'mysql')) { $p = Join-Path $BundleRoot $d if (Test-Path $p) { $mb = ((Get-ChildItem $p -Recurse -File | Measure-Object Length -Sum).Sum / 1MB) Say (" {0,-22} {1,8:N1} MB" -f $d, $mb) } else { Say (" {0,-22} {1}" -f $d, 'absent') 'DarkGray' } } Write-Host '' Say ' Compile with: iscc ShopDBFlask.iss' 'White' Say ' (Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)' 'DarkGray' Write-Host ''