Nine fixes from a review of the installer against its actual audience: DT leads at sister sites who are not Windows, IIS or Python specialists and who will lean on an AI assistant to get through it. TRUTHFULNESS. The preflight was advisory - an operator read 'IIS is not installed', pressed Next, answered five more pages and the install died partway through with Python already on the box. The results page now blocks while anything is failing, repaints on every run instead of latching after the first, and offers 'Check again' so a fixed problem does not mean starting over. On failure the wizard said 'Nothing was left running', which is false in every path because the stages run with -OnFailure never: it now says the server is part-configured, that re-running is safe, and how to remove it. The final page no longer reads 'ShopDB-Flask is ready' after a failed install. SECRETS. The generated MySQL root password went to Write-Host in a process the wizard runs hidden - so nobody saw it - and stdout is forwarded into the setup log operators are told to send to support, so it was permanently recorded for everyone who did not need it. It now goes to an ACL'd file. Database dumps, which contain every user password hash, landed in a ProgramData directory readable by every user on the box; the directory is now locked at creation. UPGRADES ON REMOTE-DATABASE SITES. mysqldump was looked for only under local MySQL install paths, so a site whose database is on another host silently skipped every pre-upgrade backup - after stage 2 had already stopped the pool and replaced the tree. Find-MysqlTool now prefers a client shipped in the bundle, stage 2 stages it onto the server, preflight reports when it is missing, and mysqlclient\ is an optional locked payload. UNINSTALL. A subpath install is an IIS Application, not a site; removing only the site left the application pointing at a deleted directory, so the parent site - at West Jefferson, the live classic ASP - served 503 on that path forever while Add/Remove Programs reported success. Uninstall now reads MOUNT_PATH and removes the application. The firewall rule was created as "$SiteName $SitePort" and removed as the literal 'ShopDB-Flask 8090', which matches nothing. DAY-2 TOOLING. Every shortcut now passes -AppRoot and -SitePort, and the console forwards them through its own elevation and 32-bit relaunches instead of discarding them - a non-default directory or port made it report a healthy site as broken, from a shortcut the installer wrote. 'Open ShopDB-Flask' resolved to a hardcoded localhost:8090 that was wrong for every subpath install; it now asks the console, which reads the address the installer recorded, and no longer demands administrator to open a browser. SMOKE TEST. The parent-site port lookup filtered for an http binding and defaulted to 80, so an https-only parent site failed a working install with a red dialog. DOCS AND /api/docs. The installer was invisible: nothing in docs/, README.md or CLAUDE.md mentioned it, so a DT lead or their assistant landed on the manual IIS runbook and hand-built the very server the installer then refuses to upgrade. docs/INSTALL-WINDOWS.md and docs/OPERATE-WINDOWS.md are now the canonical route, the two manual runbooks are bannered as reference-only, README and CLAUDE.md route by target, and llms.txt tells an assistant which document to follow and to ask for 'check -Json' before diagnosing. Both ship on the server, along with openapi.json and llms.txt - without those the self-hosted /api/docs was broken on every installed box, which matters most to the sites least able to debug it. Stage 5 now checks it actually serves. shopdb-admin.ps1 gains 'check -Json': one structured, secret-free block covering version, publishing method, IIS state, HTTP reachability, database, Python version, plugins and errors. That is the cheapest useful answer to 'the operator will ask an LLM' - it works with no infrastructure, which a install-time MCP server could not.
291 lines
14 KiB
PowerShell
291 lines
14 KiB
PowerShell
<#
|
|
.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 /<alias> (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'
|
|
|
|
# --- 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', '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 ''
|