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.
Windows installer
Builds a single self-contained .exe that installs ShopDB-Flask on an
air-gapped Windows Server. Nothing here ever touches the network at install
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.
Lives with the application on purpose. The installer depends on app internals -
flask plugin verbs, site-profile.json, MOUNT_PATH, the plugin registry -
so a separate repo would drift out of step with the thing it installs.
Files
| File | What it is |
|---|---|
shopdb-preflight.ps1 |
Stage 1. Read-only. Changes nothing, reports what this server is missing. |
shopdb-install.ps1 |
Stages 0 and 2-5 plus uninstall. All the actual work. |
shopdb-admin.ps1 |
Operator console installed alongside the app: status, restart, logs, backup, plugins. |
ShopDBFlask.iss |
Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
build-installer.sh |
Stages the bundle from a site profile. |
make-branding.py |
Generates wizard artwork and the icon from frontend/public/*.svg. |
*.bmp, shopdb.ico |
Generated artwork, committed so a Windows build box needs no Python. |
build-installer.ps1 |
The same build, natively on Windows. No Bash needed. |
bundle-lock.json |
The exact third-party payload this installer ships. Reviewed by commit. |
bundle-lock.ps1 |
Creates and checks that lock. Also runs on the target server. |
refresh-bundle-lock.ps1 |
Regenerates the lock, after showing what changed. |
verify_bundle_lock.py |
The same check for the Bash builder, so Linux needs no pwsh. |
Building
Two builders, same result. Use whichever machine you are on - build-installer.ps1
does the whole job natively so a Windows work PC needs no Bash.
# Linux / WSL
./build-installer.sh ../../site-profile.example.json
# Windows
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
Both stage the app tree, build the SPA twice, write plugins.iss, copy the
installer scripts from this directory, and then verify the third-party
payload against bundle-lock.json. They exit non-zero if it does not match.
The payload itself is added by hand, and no longer needs Windows to produce:
bundle/wheels/ pip download -r requirements.txt --only-binary=:all: \
--platform win_amd64 --python-version 314 \
--implementation cp --abi cp314 -d wheels
bundle/python/ python-3.14.x-amd64.exe
bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below)
bundle/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)
Then compile on Windows:
iscc ShopDBFlask.iss
Inno Setup 6.6.0 or newer. The wizard uses the built-in windows11 custom
style, which earlier versions reject. The script fails at compile time with that
sentence rather than with a bare "WizardStyle is invalid".
The wheelhouse is cp314-locked. A different Python minor version means a different wheelhouse; the installer will not use a Python it did not install.
What is pinned, and where
Three layers, because no one of them covers the whole problem.
| Layer | Covers | Enforced |
|---|---|---|
requirements.txt sha256 per package |
every wheel is genuinely what upstream published | pip --require-hashes at install; aborts on mismatch |
bundle-lock.json |
the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs |
| git | the application tree | code review |
--require-hashes alone is not enough. pip lists every artifact of a pinned
version - cffi 2.1.0 has 100 hashes - so it proves the wheel is genuine, not
that it is the wheel this bundle was built and tested with. It also ignores
extra files in the wheelhouse, and says nothing about the Python installer or
the MSIs, all of which run as SYSTEM on the target server.
So bundle-lock.json records an exact file set with a sha256 and a byte size
each, and verification is set equality: a missing file, an unexpected extra
file, or changed content all fail. There is no install-time override.
The lockfile is --universal, so one file serves Linux (dev, Docker, CI) and the
Windows wheelhouse. A Linux-only resolve had silently omitted colorama, a
win32-only dependency of click - which in hash-checking mode is a hard error
rather than a quiet omission.
Changing what ships
.\refresh-bundle-lock.ps1 # show what changed, write nothing
.\refresh-bundle-lock.ps1 -Yes # write it
Then commit bundle-lock.json. That commit is the review - it is the only
place a change to what runs as SYSTEM on a customer's server becomes visible to
a human. refresh-bundle-lock.ps1 refuses to overwrite an existing lock until
you have seen the diff, for that reason.
To stage a bundle before its lock exists: ALLOW_UNLOCKED=1 (Bash) or
-AllowUnlocked (PowerShell). Bundles built that way must not be shipped.
Verifying a live server, months later and offline:
shopdb-admin.ps1 verify
Servers this installer did not build
It is built for greenfield: its own Python, its own venv, its own IIS objects. Its upgrade path assumes the thing being upgraded came out of a previous run. Two guards keep it from damaging a server that was deployed by hand.
Python minor version. An existing venv is reused, which is right for a repair or an upgrade. It is wrong when the venv belongs to a different Python - the wheelhouse is tagged for one minor version, so pip would die at the first compiled package, after Python was installed and the app tree replaced. The installer compares the two up front and stops with both version numbers.
IIS objects. Switching deployment method removes the other method's artifact,
which is correct when the installer owns both and dangerous when it does not: a
wrong -MountAlias would delete a live mount with no prompt. It now refuses
unless there is a version stamp proving it made the install, or you pass
-AdoptExisting. The refusal lists exactly what it would have removed.
An existing web.config is never overwritten in either case.
For the West Jefferson production server specifically, this is a migration, not an upgrade - prod runs Python 3.13 against a hand-built deployment, so it needs a deliberate window, a database backup, and web.config reconciled by hand.
Bill of materials
Every build stages a CycloneDX 1.6 SBOM at sbom.cdx.json, inside the
application tree, so it installs onto the server with the app. Both ecosystems,
in one document:
- Python — every pin in
requirements.txt, with the sha256 the installer enforces. Environment markers are ignored: asys_platform == 'win32'dependency still installs on the target. - npm — every package in
frontend/package-lock.json. Build-only packages are markedscope: excludedrather than dropped, so "not here" is distinguishable from "not looked for".
It ships to the server because an air-gapped site cannot be scanned from anywhere else. When a CVE lands, the answer is already on the box:
shopdb-admin.ps1 verify # counts, and which bundle this is
shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version
Generated by scripts/generate_sbom.py from files that are already pinned and
committed, so it is a translation rather than a scan — no network, no extra
toolchain on the build box, and byte-identical output for the same inputs. It is
deliberately not in bundle-lock.json: its provenance is git, not the payload.
Client IP addresses
IIS does not set X-Forwarded-For on its own, and HttpPlatformHandler connects
from loopback. Without a rule, every client reads as 127.0.0.1 - so the
GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host
login rate limiting all stop working, silently.
The wizard asks, because the two answers are mutually exclusive:
- Clients connect directly (
-ClientIpSource direct, the default) - installs URL Rewrite from the bundle and setsX-Forwarded-ForfromREMOTE_ADDR. Overwriting the header is what stops a client spoofing its own. - A proxy sits in front (
-ClientIpSource proxy) - leaves the rule off. Behind ARR or a load balancerREMOTE_ADDRis the proxy, so applying the rule would discard the real client IP.
An existing web.config is never overwritten - it is the one file on a server
that legitimately carries hand-edits. The installer reports what it found
instead.
Deployment methods
Chosen in the wizard, and the bundle carries a SPA build for each because Vite compiles the base path in - it cannot be switched at install time.
- Its own site on a port (default 8090).
- Subpath under an existing site, e.g.
http://<server-fqdn>/shopdb/. Needs no new DNS record. Three things must agree - the IIS application alias,MOUNT_PATHin.env, and the SPA's build-time base - so the alias is fixed per bundle (SUBPATH_ALIAS, defaultshopdb) and the installer refuses if the bundle's build does not match what was asked for.
Switching between methods removes the other one's IIS artifact and reconciles
MOUNT_PATH and CORS_ORIGINS, so a server never ends up with both.
Upgrades
Run a newer installer over an existing install. It:
- backs the database up first, verifies the dump is complete, and refuses to migrate if it cannot;
- restores from that backup if migrations fail, and reports honestly that DDL the failed migration committed cannot be undone;
- refuses to run a bundle older than what is installed;
- keeps
.envunless new credentials are supplied, and copies it aside first; - stops the app pool before replacing files, then starts it again.
Whether a run is an upgrade is decided by probing the target database, not by whether the app directory exists - a rebuilt server pointed at an existing database is an upgrade, and treating it as fresh would drop tables.
Testing notes
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.0 and an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and rollback, uninstall, and both deployment methods including switching between them.
Not yet verified: a fully air-gapped run with the network disabled at the
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
which sends no Origin header - so CORS_ORIGINS is untested in anger).