This directory builds NodePilot as a local desktop application for Windows 11 x64: a single
signed .exe that installs the app, a bundled .NET 10 runtime, and a local PostgreSQL server,
then runs everything as background Windows services with a native Electron window on top. No .NET
runtime, no external database, and no internet connection are required at install time.
It is a distinct shipping target from the server rollout in ../README.md — that one
is a domain-joined Windows Server as a service behind Kestrel TLS with an external database.
NodePilot is an orchestrator: schedule / file-watcher / webhook triggers must fire even when no window is open. So the backend runs as an always-on service and the Electron shell is a thin viewer.
Installer (.exe, signed)
├─ C:\Program Files\NodePilot\ app\ (self-contained API + wwwroot + Modules) · desktop\ (Electron) · pgsql\ (PG16 server runtime) · deploy\ · tools\np (np CLI) · tools\mcp (nodepilot-mcp)
├─ C:\ProgramData\NodePilot\ pgdata\ · logs\ · secrets\ · keys · admin-setup.token · desktop.json · backups\
├─ Service "NodePilotDb" (postgres, NetworkService, 127.0.0.1:<pgport>, boot-start)
├─ Service "NodePilot" (NodePilot.Api.exe, LocalSystem, https://127.0.0.1:<apiport>, boot-start, depend= NodePilotDb)
└─ Start Menu (+ Desktop shortcut, optional) → NodePilot.exe (Electron → loads the origin from desktop.json)
The .NET backend already serves the SPA same-origin (UseStaticFiles + MapFallbackToFile), so the
Electron shell never bundles or renders the frontend itself — it points a hardened BrowserWindow
at https://localhost:<port> and manages nothing but the window and the tray.
The desktop package runs with ASPNETCORE_ENVIRONMENT=Production (full hardening: security headers,
Swagger off, inline-password guard) plus a new posture key Deployment:Mode=Desktop. Desktop mode
relaxes only the things that make sense for a machine talking to itself:
DatabaseTlsBootValidatoracceptsDatabase:AllowInsecureTls=trueonly for a loopback DB host under Desktop mode (a 127.0.0.1 Postgres with no PKI). Remote hosts still fail closed.- Kestrel binds loopback only (
ListenLocalhost), never every interface.
Waiting for the database before the migration bootstrap used to be listed here as a third Desktop
relaxation. It is not one any more: DatabaseReadinessGate runs in both deployment modes,
because both race the same way at boot — Desktop against the bundled Postgres service, Server
against a remote database still recovering. The bound is Database:StartupWaitSeconds (default
300 s). Only reachability is retried; a migration/schema error surfaces immediately.
Runtime outages: If the bundled
NodePilotDbservice stops or hangs, the API stays up and answers503 DATABASE_UNAVAILABLE;/healthz/readyreturns 503 while/healthz/databasereports the state and reason. The UI shows a banner and resumes automatically after Postgres recovers. Running workflows pause at a durable step boundary, and trigger fires observed during the outage are not replayed.RejectedByServerrequires fixing the local credentials, database or TLS setup; restart the service when its connection settings changed.
Everything else stays hardened. Deployment:Mode defaults to Server; an unknown value is a boot error.
%ProgramData%\NodePilot\desktop.json tells the Electron shell what to load and trust:
{ "schemaVersion": 1, "origin": "https://localhost:47000",
"certificateSha256": "<uppercase-hex>", "serviceName": "NodePilot" }The port is not fixed at 47000: Provision-LocalDb.ps1 picks the first free port from 47000
upwards (and 47100–47149 for Postgres), so an installation that hit a busy port looks different.
Read the actual origin out of desktop.json rather than assuming the number above.
The DB password is never here — it lives only in the ACL-restricted ConnectionStrings__Postgres
service-environment value.
- Loopback binds the whole listener, not individual routes.
Deployment:Mode=DesktopforcesListenLocalhost, so the SPA, every/api/*endpoint,/hubs/*,/healthzand/api/webhooks/*are reachable from that machine only — nothing listens on a network interface and no firewall rule is created. A common misreading is that only the trigger route is blocked while the rest of the API stays reachable; it is not. The practical rule: anything NodePilot initiates works, anything that must reach in does not. Schedule/file-watcher/database/event-log triggers and all outbound automation (WinRM,restApi,sql, SMTP, alerting webhooks) are unaffected; inbound webhooks and the external trigger API (also disabled because no scoped external-trigger key is configured) are unusable. - API runs as LocalSystem (zero-config). Consequence: loopback
runScriptactivities run with SYSTEM rights. This is an explicit v1 decision for a single-user local orchestrator. - Postgres runs as NetworkService, bound to 127.0.0.1 only.
- Loopback TLS by pinning, not a root CA. The installer creates a self-signed
localhostcertificate inLocalMachine\My; the Electron session pins it by SHA-256 fingerprint. No system trust store is modified, so an ordinary browser visiting the URL may warn — that is expected; Electron is the supported entry point. - Electron hardening: the SPA window has
contextIsolation,sandbox,webSecurityon,nodeIntegrationoff, and no preload / no IPC. Navigation off-origin, popups, and permission requests are all blocked. Downloads are allowed only when the whole URL chain stays on the app's own origin, and Electron's native Save As dialog picks the destination — no renderer-supplied path. - First-run token never reaches the renderer. See below.
- Minimal ACLs on ProgramData, the service registry key, the cert key,
pgdata,secrets\,backups\, and the per-user handoff file.
-
On first boot (empty users table) the API writes a one-shot
admin-setup.token(SYSTEM-owned). -
The elevated installer hands it to the user session. The token's ACL is owner-only, so even an elevated Administrator can neither read it nor change its DACL — the provisioner therefore first takes ownership for
BUILTIN\Administrators(takeown /a) and grants that group read, then writes the value to%LOCALAPPDATA%\NodePilot\admin-setup.handoff(restricted to that user + SYSTEM) and Inno launches Electron as that user. Owner and every remaining ACE stay inside the backend's trusted set, soAdminBootstrap.Validatestill accepts the token.Which user is "that user" is resolved explicitly, not assumed. The installer runs elevated but launches the shell with
runasoriginaluser, so the two are different principals whenever a standard user elevates with someone else's administrator credentials — the normal case on a managed machine.Get-InteractiveUserProfiletherefore resolves the console user viaWin32_ComputerSystem.UserNameand reads their profile directory out of theProfileListregistry, instead of using the elevated process's own%LOCALAPPDATA%. Inno's{localappdata}would be no better: it expands in the elevated context too.-HandoffUserProfileoverrides the resolution for the dev loop and for tests. Getting this wrong strands the user on a login form for an account that does not exist yet, with the only remaining token copy SYSTEM-owned — recovery steps are indocs/desktop-troubleshooting.md. -
Electron shows a local setup page whose only bridge is
completeAdminSetup({username,password}). -
The main process reads the handoff token,
POST /api/auth/loginwith headerX-Setup-Token, shares the returned cookies with the SPA session, deletes both token copies, and opens the preload-less SPA window. The token is never exposed to the renderer.
Requirements: .NET 10 SDK, Node + npm, Inno Setup 6 (ISCC.exe),
and a PostgreSQL 16 binaries folder (the pgsql directory from the EDB zip distribution).
The major version is enforced, not assumed: the build reads it out of pgsql\bin\postgres.exe — the
binary, never the path, because EDB's portable zip unpacks to a plain pgsql folder with no version
in it, and a path that does carry one is still just a renameable label — and refuses
anything but 16 before it stages a single file. A cluster initialised by one major cannot be opened
by another, and this package upgrades in place over an existing pgdata — so a 17.x payload would
compile, sign and ship without a warning, then fail against every installation it reached.
./Build-DesktopInstaller.ps1 -PgBinariesPath 'C:\path\to\pgsql' -Version 1.0.0
# -> out\NodePilot-Desktop-Setup-1.0.0.exe (sign with your Authenticode cert before distribution)The build generates the icons via scripts/generate-desktop-icons.ps1 (see Icons below),
publishes the API self-contained (-r win-x64 --self-contained true, no single-file — the PowerShell
SDK is folder-deployed), publishes the operator clients (np, nodepilot-mcp) self-contained to
tools\np and tools\mcp (self-contained because the desktop package promises zero prerequisites),
builds the SPA into app\wwwroot and the documentation site into app\wwwroot\docs, packages the
Electron shell with Electron Packager, stages the Postgres server runtime + scripts, and compiles
the installer.
The documentation is why the package needs no internet to be usable: the API serves it at /docs,
and the documentation button at the bottom left of the sidebar, next to the skin and language
controls, opens it. Because the shell has no menu bar and no back gesture, it gets a window of its
own rather than replacing the app view — that window is
pinned to /docs for navigations and redirects, so it cannot become a second, chrome-less view
of the application. Links that lead out of the documentation are handed to the system browser
(https: only); the shell never renders foreign content itself. See src/security.ts.
Two build steps are load-bearing and easy to break by accident:
app\Modules(PowerShell built-in modules).Microsoft.PowerShell.SDKships Utility, Management, CimCmdlets etc. underruntimes\win\lib\<tfm>\Modules, but the hosted runspace looks for them at$PSHOME\Modules— i.e. next toSystem.Management.Automation.dllin the app root. The build copies them there and fails ifMicrosoft.PowerShell.Utilityis missing afterwards. Without this everyrunScriptfails with "the module could not be loaded … compatible with the 'Core' edition" unless PowerShell 7 happens to be installed system-wide — which the desktop package must not depend on.- PostgreSQL subset. Only
bin,libandshareare bundled. A stock EDB distribution also carries pgAdmin 4 (~630 MB, a GUI with its own Chromium),doc,includeand StackBuilder, none of which NodePilot uses; excluding them takes the installer from ~350 MB to ~176 MB.shareis not optional —initdbfails withoutpostgres.bkiand the timezone data.-PgBinariesPathmay therefore point at a full EDB folder or an already-trimmed one.
scripts/generate-desktop-icons.ps1 renders src/nodepilot-desktop/assets/ from the SPA's tracked
brand assets (src/nodepilot-ui/public/appicon-<skin>.png). The output is gitignored; the sources
are versioned, so a clean clone can always rebuild it.
| Output | Used for |
|---|---|
icon.ico (16/32/48/256) |
exe, installer, Start-Menu entry, Explorer |
icon.png / tray.png |
every window + the tray until the SPA reports its skin |
skins\<id>.png / <id>-tray.png |
window + tray icon per SPA color skin |
With -WizardImageDirectory the same script also writes the Inno Setup wizard bitmaps into the
stage directory, which both installers reference:
| Output | Used for |
|---|---|
wizard-image-<w>x<h>.bmp |
left banner of the welcome and finished pages (logo on a dark navy gradient) |
wizard-small-<w>x<h>.bmp |
header of every inner wizard page (logo on white) |
One file per scaling step because Inno picks the candidate closest to the size it needs at the current display DPI, and 24bpp because Inno ignores a BMP alpha channel.
The static default is blue — rendered from appicon-dark.png, not from the untinted orange
source art appicon.png (-DefaultSkin picks a different one). At runtime the shell follows the
skin: the SPA rewrites <link rel="icon"> to /appicon-<skin>.png on every skin switch, Chromium
reports that as page-favicon-updated, and src/skins.ts maps it back onto skins\<id>.*. That
keeps the production SPA window preload-less and IPC-free — the shell reads a one-way signal the
renderer already broadcasts. The per-skin set is discovered from the appicon-*.png files, so a new
UI skin needs no change here.
The .exe/installer/Start-Menu icon cannot follow a skin — Windows resolves those from the file
itself, which is why the shipped default matters.
Running the Electron shell straight from source (npm start, see below) starts with an empty
assets/: run npm run icons in src/nodepilot-desktop once to populate it.
- Install: run the
.exeas a local administrator (UAC). It lays down files, runsProvision-LocalDb.ps1(Postgres cluster + service, cert, config, API service, desktop.json, token handoff), and launches the shell. Provisioning runs fromCurStepChanged/ssPostInstall, not from[Run], so its exit code is inspected: a failed run reports an error naming%TEMP%\nodepilot-provision.logand suppresses the "Launch NodePilot" step, instead of finishing green with a dead app. Setup is deliberately not rolled back at that point — the files are already in place and a rollback would take the database with it.- There is no default account or password. The first administrator is whoever completes the
shell's setup page. Data left behind by an uninstall keeps its accounts, so setup shows a page
(only when no installation exists but
ProgramData\NodePilotdoes) that keeps it or deletes it; unattended it keeps it,/DISCARDDATA=1deletes it. - Service start window. Windows ends a service that has not connected to the SCM within 30 s,
and the API connects only after it has loaded its binaries, waited for and migrated the database.
The provisioner therefore reads every binary once (cold file cache, antivirus scan), starts the
API only once
pg_isreadyanswers, and starts it again (up to five times) if the SCM ended it.
- There is no default account or password. The first administrator is whoever completes the
shell's setup page. Data left behind by an uninstall keeps its accounts, so setup shows a page
(only when no installation exists but
- When something goes wrong:
docs/desktop-troubleshooting.md— log locations, the "setup page never appeared" recovery, port-pool exhaustion, and manual removal of an installation that predates the uninstall question. - Update / over-install: run a newer installer, or the same one again. Before any file is copied,
PrepareToInstallrunsPrepare-DesktopSetup.ps1from the new installer (extracted to{tmp}from thesetup\staging tree, because the copies inside the installation belong to the old version): an ACL-protectedpg_dump, thenStop-DesktopRuntimeends the shell and clients, stops both services and waits for their processes, including a postmaster that outlived its service wrapper. If that fails, setup aborts before touching a program file. Re-provisioning then deletes both services (waiting until the SCM has really removed them, not merely marked them for deletion), recreates them, and reuses the cluster (initdbis skipped whenpgdata\PG_VERSIONexists), so data survives.Update-Desktop.ps1also implements a full staged update with binary + config + DB rollback for direct/advanced use. Postgres major upgrades are out of scope for v1. - Uninstall: asks whether to keep the data (Keep data / Delete everything / Cancel; unattended
keeps it,
/PURGEDATA=1deletes it) and runsUninstall-Desktop.ps1fromCurUninstallStepChanged, not[UninstallRun], whose parameters Inno freezes at install time. The script ends the shell and clients, deletes both services, removes the certificate with its private key, the setup handoffs and the rendered configuration; with-PurgeDataalsoProgramData\NodePilot(taking ownership of what the API and Postgres protected) and theNodePilotfolders in every profile (%APPDATA%: shell session +np/nodepilot-mcpconfig,%LOCALAPPDATA%: handoff). It exits 1 when anything is left, and the uninstaller reports%TEMP%\nodepilot-uninstall.log. - Antivirus: the installer sets no AV exclusions. Electron's Chromium native DLLs, Postgres' WAL
I/O and the generated
%TEMP%\nodepilot_*.ps1scripts are the usual false-positive sources — a hand-off list with per-entry rationale and residual risk is indocs/av-exclusions.md.
| File | Role |
|---|---|
Build-DesktopInstaller.ps1 |
Build orchestrator (icons + publish + SPA + Modules + Electron + operator clients → tools\{np,mcp} + PG subset + ISCC). |
../../scripts/generate-desktop-icons.ps1 |
Icon set and Inno wizard bitmaps from the SPA brand assets (default + per-skin); also runnable standalone. |
Sync-DesktopApp.ps1 |
Dev loop: pushes local changes into an installed app in ~1 min (see below). |
NodePilot.iss |
Inno Setup installer definition. |
Provision-LocalDb.ps1 |
First-run/repeatable runtime provisioner (DB, services, cert, config, handoff). |
DesktopRuntime.ps1 |
Shared stop/removal helpers: waits for service processes, not for the reported status. |
Prepare-DesktopSetup.ps1 |
Run by setup before copying files: backup, stop everything, optionally discard old data. |
Update-Desktop.ps1 |
Pre-upgrade backup + full staged update with rollback. |
Uninstall-Desktop.ps1 |
Runtime removal; with -PurgeData also all data and per-user folders. |
appsettings.Desktop.json.template |
Production-hardened desktop config (rendered by the provisioner). |
A full installer build takes ~10–15 minutes, but it is only needed to distribute. The installed app is just files plus two services, so day-to-day changes have much shorter loops:
| Changing | Fastest loop | Time |
|---|---|---|
| Electron shell | cd src/nodepilot-desktop; npm start — runs from source against the installed backend (it reads %ProgramData%\NodePilot\desktop.json), no packaging at all |
seconds |
| Electron shell, icons | npm run icons in the same folder — regenerates assets/ (empty in a fresh clone) |
seconds |
| Backend / SPA, normal work | ordinary dev mode (backend on 5000, Vite on 5173 with HMR) | seconds |
| Backend / SPA, as packaged | Sync-DesktopApp.ps1 -Component api|spa|shell|all (elevated) — incremental publish/build, robocopy into the installation, service restart + health poll |
~1 min |
| Distribution | Build-DesktopInstaller.ps1 |
~10–15 min |
Use the sync script when the packaging matters — service identity is LocalSystem, the DB is the
bundled Postgres, TLS is the pinned loopback cert — none of which dev mode reproduces. It never
mirrors over app\Modules or app\wwwroot, so the PowerShell modules and SPA stay intact.
The spa component syncs two bundles, and the order matters: the SPA mirror runs against
app\wwwroot with /MIR, which deletes whatever the source lacks, so it excludes
app\wwwroot\docs (/XD) and the documentation is mirrored separately afterwards. Without that
exclusion every sync would silently remove the documentation, and /docs would 404 long after the
cause. DocsSiteDeploymentTests guards it.
Quit the installed shell first (tray → Quit NodePilot) before npm start: both resolve to the same
productName, so the single-instance lock makes the second one focus the first and exit. Shell
changes reach the installed app only through a new installer — app.asar is not patchable.
Honest inventory so nobody assumes more coverage than exists:
- The Electron module's pure logic is unit-tested; its Electron-runtime behaviour is not.
npm run test:runinsrc/nodepilot-desktop(vitest, node environment) coversconfig.ts(desktop.json handoff validation — origin, fingerprint, serviceName injection barrier),security.ts(certificate-pin match/mismatch/parse-failure, non-loopback rejection, permission blocking, the download gate — same-origin allowed, foreign origins and unregistered windows blocked — and navigation containment),skins.ts(favicon → skin-icon resolution, including the path-charset guard on the renderer-supplied id) andsetupFlow.ts(setup page vs. app window, handling of a rejected setup token). What still needs a real Electron process — the setup-token IPC guard, the elevatedrestartBackendpath, window lifecycle, and whether Chromium actually reports the SPA's favicon swap — is verified only by hand. The backend half of the feature is unit-tested (DeploymentModeTests,DatabaseTlsBootValidatorTests,DatabaseReadinessGateTests,KestrelHttpsConfiguratorTests). - No CI coverage for
deploy/desktop/*. ThedesktopCI job runsnpm audit, typecheck and vitest forsrc/nodepilot-desktop, and the nightly script adds adesktop-vitestsuite; there is still no lint config.Test-DeploymentTemplates.ps1pins the desktop setup's contracts as text (provisioning exit code, handoff profile, stop before copy, uninstall data question, service removal, staging of dot-sourced helpers) but runs none of the scripts, andappsettings.Desktop.json.templateis never parsed by any check. - The vulnerable legacy ZIP extractor is not installed. Electron Packager 20.3.0 uses Electron's hardened native extractor, both are pinned exactly, malicious symlink archives are tested, and a final filesystem-boundary gate rejects links/reparse paths before Inno Setup can recursively copy the output. The packaged shell still has zero runtime npm dependencies.
- The installer is unsigned unless you ask for a signature.
Build-DesktopInstaller.ps1alone never signs. Building throughdeploy\Build-Artifact.ps1 -IncludeDesktopInstaller -InstallerSigningCertificateThumbprint <tp>signs it as part of the run — which is where signing belongs, because doing it afterwards rewrites the.exeand invalidates its entry inNodePilot-<version>.SHA256SUMS.txt. A self-signed publisher still leaves SmartScreen warning on first launch; only a reputation-carrying certificate silences that. - Not exercised end-to-end: upgrade with a forced health failure (the rollback path),
process-isolated
runScript(config.isolated), and the interactive wizard/uninstall dialogs (the install/over-install/uninstall/reinstall paths were verified unattended on a clean Windows 11 VM). - Postgres major-version upgrades and Electron auto-update are out of scope by design.
The PowerShell + Inno + provisioning paths cannot be exercised on a build host; validate on a clean Windows 11 x64 VM without .NET/Postgres preinstalled:
- Install → both services running,
pgdatainitialized,desktop.jsonwritten, cert pinned. GET https://127.0.0.1:<apiport>/healthz/ready→ 200 (migration ran).- Launch shell → SPA loads without a cert warning → first-run admin creation → a
runScriptworkflow againstlocalhostruns in-process. - Close the window → a
scheduleTriggerstill fires in the background → reopen is single-instance. - Reboot → services auto-start. Upgrade and same-version over-install with the shell open → users/credentials/workflows/PG data survive, no file-in-use errors; forced health failure rolls back.
- Uninstall Keep data → services, certificate + private key and Program Files gone,
pgdatapreserved; reinstall → old administrator signs in, no setup page. - Uninstall Delete everything (
/PURGEDATA=1) → additionallyProgramData\NodePilotand the per-userNodePilotfolders gone; reinstall → setup page, new administrator. - Leftover data + setup's Delete the existing data (
/DISCARDDATA=1) → fresh database.