feat(scripts): a PowerShell script to start the local environment (#125)
Linting / lint (pull_request) Successful in 1m45s
SonarQube Analysis / sonarqube (pull_request) Failing after 13m24s

Bringing the app up locally was seven or eight commands in a particular order: a Postgres container on a port that is not blocked, the six environment variables the backend refuses to boot without, migrations, a TypeScript build, the backend, then Vite. None of it hard, all of it tedious, and documented nowhere outside CI workflows written for a Linux runner.

scripts/start-local.ps1 does the sequence. It reuses an existing container rather than recreating one, skips npm install when node_modules is already there, leaves alone anything already listening on a port it wanted, and waits on pg_isready and a 200 from /api/config rather than sleeping a fixed number of seconds. -Fresh recreates the database, -Stop tears everything down by the process ids it recorded rather than by port, since killing by port would also kill whatever else happened to be listening.

Two things this got wrong first time round, both found by running it rather than by reading it.

$ErrorActionPreference = 'Stop' does not stop a PowerShell script when a native executable exits non-zero, only when a cmdlet throws. Every command here is node, npm or docker, so the first run printed a stack trace from a failed migration, carried straight on, and reported a healthy stack sitting on a database with no tables in it. That is the worst kind of wrong: a green summary over a broken environment. Native calls now go through Invoke-Checked, which tests $LASTEXITCODE and throws.

The migration failed because node on the PATH was v18.16.1. node-pg-migrate pulls in an lru-cache that calls diagnostics_channel.tracingChannel, which does not exist before Node 20, and the failure surfaces as "(0 , U.tracingChannel) is not a function" from a minified file - which says nothing whatsoever about Node versions. The script now checks the major version first and says what to do about it, so the confusing crash becomes one clear line before anything else runs.

The port default is 55500 rather than anything near 55432, which is reserved by Hyper-V on this machine. Docker's message when it cannot bind a reserved port does not mention reservations, so the failure path names the likely cause and prints the netsh command that lists the reserved ranges.

Verification, all observed rather than assumed: the version guard was made to fire on Node 18 and produced the intended message. A -Fresh run on Node 24 applied all six migrations, and psql then showed thirteen tables where the broken run had none. /api/config and the storefront both answer 200. -Stop stopped both tracked processes and the container. A second run with the dev server already up detected it and left it alone rather than failing.

.local/ holds the logs, pids and uploads, and is gitignored.

Closes #125
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 11:07:06 -05:00
parent 46f88b4bbb
commit 40b483fc30
2 changed files with 302 additions and 0 deletions
+2
View File
@@ -10,5 +10,7 @@ playwright-report/
test-results/
.env
.superpowers/
# Logs, pids and uploads written by scripts/start-local.ps1
.local/
.scannerwork/
.nyc_output/
+300
View File
@@ -0,0 +1,300 @@
#Requires -Version 7
<#
.SYNOPSIS
Starts the Redefined Designs stack locally for testing and review.
.DESCRIPTION
Brings up a Postgres container, runs migrations, builds and starts the
backend, and starts the Vite dev server. Safe to run repeatedly: an
existing container is reused rather than recreated, and processes already
listening are left alone.
Logs and process ids go to .local/ at the repository root.
.PARAMETER Fresh
Drop the database container and its data before starting, so migrations
run against an empty database.
.PARAMETER Stop
Stop the backend, the dev server and the database container, then exit.
.PARAMETER DbPort
Host port for Postgres. Change it if something already holds the default.
.EXAMPLE
.\scripts\start-local.ps1
.\scripts\start-local.ps1 -Fresh
.\scripts\start-local.ps1 -Stop
#>
[CmdletBinding()]
param(
# Deliberately not 55432. That port is reserved by Hyper-V on at least one
# machine here, and Docker's failure when it cannot bind does not mention
# reservations, which has cost time more than once.
[int]$DbPort = 55500,
[int]$ApiPort = 3000,
[int]$WebPort = 5173,
[switch]$Fresh,
[switch]$Stop
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$StateDir = Join-Path $RepoRoot '.local'
$PidFile = Join-Path $StateDir 'pids.json'
$Container = 'redefined-designs-local-db'
$DbUser = 'redefined_local'
$DbPassword = 'redefined_local'
$DbName = 'redefined_local'
function Write-Step { param([string]$Message) Write-Host "==> $Message" -ForegroundColor Cyan }
function Write-Note { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray }
function Write-Good { param([string]$Message) Write-Host " $Message" -ForegroundColor Green }
# $ErrorActionPreference = 'Stop' does NOT stop the script when a native
# executable exits non-zero, only when a cmdlet throws. Everything here is
# node, npm or docker, so without this wrapper a failed migration is a line of
# red text the script prints and then carries straight past. It did exactly
# that once, and reported a healthy stack sitting on an empty database.
function Invoke-Checked {
param([scriptblock]$Command, [string]$What)
& $Command
if ($LASTEXITCODE -ne 0) {
throw "$What failed (exit code $LASTEXITCODE)."
}
}
function Assert-Docker {
docker info *>$null
if ($LASTEXITCODE -ne 0) {
throw "Docker is not running. Start Docker Desktop and try again."
}
}
# node-pg-migrate pulls in an lru-cache that calls
# diagnostics_channel.tracingChannel, which does not exist before Node 20. On
# Node 18 the migration dies in minified library code with "(0 , U.tracingChannel)
# is not a function", which says nothing about versions. CI runs Node 20.
function Assert-NodeVersion {
$raw = (node --version)
$major = [int](($raw -replace '^v', '') -split '\.')[0]
if ($major -lt 20) {
throw @"
Node $raw is too old. This needs Node 20 or newer.
If you use nvm-windows:
nvm use 24.13.1
Then run this script again in a new shell.
"@
}
Write-Note "node $raw"
}
# Reads back only the ids this script wrote. Killing by port would be shorter
# and would also kill whatever else happened to be listening.
function Get-TrackedProcesses {
if (-not (Test-Path $PidFile)) { return @{} }
try { return (Get-Content $PidFile -Raw | ConvertFrom-Json -AsHashtable) }
catch { return @{} }
}
function Stop-Tracked {
param([string]$Name)
$tracked = Get-TrackedProcesses
if (-not $tracked.ContainsKey($Name)) { return }
$process = Get-Process -Id $tracked[$Name] -ErrorAction SilentlyContinue
if ($process) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Write-Good "stopped $Name (pid $($process.Id))"
}
}
function Set-Tracked {
param([string]$Name, [int]$ProcessId)
$tracked = Get-TrackedProcesses
$tracked[$Name] = $ProcessId
$tracked | ConvertTo-Json | Set-Content $PidFile
}
function Test-Listening {
param([int]$Port)
return [bool](Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
}
function Wait-For {
param(
[scriptblock]$Condition,
[string]$What,
[int]$TimeoutSeconds = 60
)
for ($i = 1; $i -le $TimeoutSeconds; $i++) {
if (& $Condition) {
Write-Good "$What ready after ${i}s"
return
}
Start-Sleep -Seconds 1
}
throw "$What did not become ready within ${TimeoutSeconds}s."
}
function Stop-Environment {
Write-Step 'Stopping'
Stop-Tracked 'backend'
Stop-Tracked 'frontend'
docker stop $Container *>$null
if ($LASTEXITCODE -eq 0) { Write-Good "stopped container $Container" }
else { Write-Note "container $Container was not running" }
Remove-Item $PidFile -ErrorAction SilentlyContinue
Write-Host ''
Write-Host 'Stopped.' -ForegroundColor Green
}
function Start-Database {
if ($Fresh) {
Write-Step 'Removing the existing database (-Fresh)'
docker rm -f $Container *>$null
Write-Good 'removed'
}
$existing = (docker ps -a --filter "name=^/$Container$" --format '{{.Names}}')
if ($existing -eq $Container) {
Write-Step "Reusing the database container"
docker start $Container *>$null
}
else {
Write-Step "Creating the database container on port $DbPort"
docker run -d --name $Container `
-e "POSTGRES_USER=$DbUser" `
-e "POSTGRES_PASSWORD=$DbPassword" `
-e "POSTGRES_DB=$DbName" `
-p "${DbPort}:5432" `
postgres:16 *>$null
if ($LASTEXITCODE -ne 0) {
# The message Docker gives for a reserved port does not say
# "reserved", so name the likely cause and the way out of it.
throw @"
Could not start Postgres on port $DbPort.
If the port is in use, or reserved by Hyper-V (which silently claims ranges on
Windows), pick another one:
.\scripts\start-local.ps1 -DbPort 55600
Reserved ranges: netsh interface ipv4 show excludedportrange protocol=tcp
"@
}
}
Wait-For -What 'Postgres' -Condition {
docker exec $Container pg_isready -U $DbUser -d $DbName *>$null
$LASTEXITCODE -eq 0
}
}
function Install-IfMissing {
param([string]$Directory)
$name = Split-Path -Leaf $Directory
if (Test-Path (Join-Path $Directory 'node_modules')) {
Write-Note "$name dependencies already installed"
return
}
Write-Step "Installing $name dependencies"
Push-Location $Directory
try { Invoke-Checked { npm install } "$name npm install" } finally { Pop-Location }
}
function Start-Backend {
$backend = Join-Path $RepoRoot 'backend'
# The six the backend refuses to boot without, plus the two that make a
# local run behave. Set in this session so the child process inherits them.
$env:PGHOST = 'localhost'
$env:PGPORT = "$DbPort"
$env:PGUSER = $DbUser
$env:PGPASSWORD = $DbPassword
$env:PGDATABASE = $DbName
$env:UPLOADS_DIR = (Join-Path $StateDir 'uploads')
$env:PORT = "$ApiPort"
# No PayPal credentials locally. DEMO_MODE lets the whole cart and checkout
# path run without them and with no way to reach live PayPal.
$env:DEMO_MODE = 'true'
New-Item -ItemType Directory -Force -Path $env:UPLOADS_DIR *>$null
Write-Step 'Running migrations'
Push-Location $backend
try {
Invoke-Checked { node migrate.js up } 'Migrations'
Write-Step 'Building the backend'
Invoke-Checked { npm run build } 'Backend build'
}
finally { Pop-Location }
if (Test-Listening -Port $ApiPort) {
Write-Note "something is already listening on $ApiPort; leaving it alone"
return
}
Write-Step "Starting the backend on $ApiPort"
# Separate files: Start-Process cannot redirect both streams to one path.
$process = Start-Process -FilePath 'node' -ArgumentList 'dist/server.js' `
-WorkingDirectory $backend `
-RedirectStandardOutput (Join-Path $StateDir 'backend.log') `
-RedirectStandardError (Join-Path $StateDir 'backend.err.log') `
-WindowStyle Hidden -PassThru
Set-Tracked 'backend' $process.Id
Wait-For -What 'Backend' -Condition {
try {
$null = Invoke-WebRequest "http://localhost:$ApiPort/api/config" -TimeoutSec 2 -UseBasicParsing
$true
}
catch { $false }
}
}
function Start-Frontend {
if (Test-Listening -Port $WebPort) {
Write-Note "something is already listening on $WebPort; leaving it alone"
return
}
Write-Step "Starting the dev server on $WebPort"
$process = Start-Process -FilePath 'npm.cmd' -ArgumentList 'run', 'dev' `
-WorkingDirectory (Join-Path $RepoRoot 'frontend') `
-RedirectStandardOutput (Join-Path $StateDir 'frontend.log') `
-RedirectStandardError (Join-Path $StateDir 'frontend.err.log') `
-WindowStyle Hidden -PassThru
Set-Tracked 'frontend' $process.Id
Wait-For -What 'Dev server' -Condition { Test-Listening -Port $WebPort }
}
New-Item -ItemType Directory -Force -Path $StateDir *>$null
if ($Stop) {
Stop-Environment
return
}
Assert-NodeVersion
Assert-Docker
Start-Database
Install-IfMissing (Join-Path $RepoRoot 'backend')
Install-IfMissing (Join-Path $RepoRoot 'frontend')
Start-Backend
Start-Frontend
Write-Host ''
Write-Host 'Running.' -ForegroundColor Green
Write-Host " Storefront http://localhost:$WebPort"
Write-Host " Admin http://localhost:$WebPort/admin"
Write-Host " API http://localhost:$ApiPort/api/config"
Write-Host " Postgres localhost:$DbPort ($DbUser / $DbPassword / $DbName)"
Write-Host ''
Write-Host " Logs $StateDir"
Write-Host " Stop .\scripts\start-local.ps1 -Stop"
Write-Host ''