start-local.ps1 knew exactly what was wrong when Node was too old and then made you fix it by hand. Assert-NodeVersion read node --version, found a major below 20, and threw a message telling you to run `nvm use 24.13.1` and start again in a new shell. A good error for a problem the script could simply solve — and since nvm's default here is 18.16.1, it was hit on every fresh shell. It now runs `nvm use latest` itself and -Stop puts the machine back to 18.16.1. The revert also runs when a start fails partway: without that, a run dying in migrations leaves the machine switched with nothing started, and the -Stop that would restore it is never reached. nvm rewrites a machine-global symlink rather than changing one shell, so this changes the Node version for every terminal on the machine while a script runs. That is the intent — the point is to work in whatever shell is already open — but it is announced every time rather than done quietly. The switch is verified rather than trusted. nvm-windows exits 0 for switches that did not happen: a version it cannot find, a symlink it cannot rewrite without elevation, and — observed here — a rewrite issued immediately after another one, where the directory symlink is briefly still the old target. That last case turned up while testing this change: `nvm use latest` reported success and left Node on 18.16.1. So the result is read back and retried once, and nvm's own output is captured rather than discarded, because suppressing it hid the only message that explained the failure. run-tests.ps1 runs the suites: -Suite unit|integration|e2e|all. One script with a parameter rather than three, because the version switch, the database bring-up and the TEST_PGPORT handling are shared and three copies would drift. The integration suite gets its own throwaway Postgres started and stopped around it, in a finally so a failing suite still tidies up. The e2e suite checks the backend is answering first and says what to start, rather than leaving twenty-five specs to fail on a refused connection that names nothing. `all` runs cheapest and most isolated first, so a break several suites would show is reported by the one that localises it best. The version switching lives in scripts/NodeVersion.ps1, dot-sourced by both, since two copies of it would drift and the half that drifts is the half nobody runs. Closes #140
301 lines
10 KiB
PowerShell
301 lines
10 KiB
PowerShell
#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,
|
|
# What -Stop puts the machine back to. nvm's default here is 18.16.1, which
|
|
# is too old to run this project's tooling but is what everything else on
|
|
# the machine expects.
|
|
[string]$DefaultNodeVersion = '18.16.1'
|
|
)
|
|
|
|
$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."
|
|
}
|
|
}
|
|
|
|
. (Join-Path $PSScriptRoot 'NodeVersion.ps1')
|
|
|
|
# Bound once so the shared switcher reports through this script's own output
|
|
# style rather than printing in a voice of its own.
|
|
$NodeOut = @{ Step = ${function:Write-Step}; Note = ${function:Write-Note} }
|
|
|
|
# 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
|
|
Restore-Node -Version $DefaultNodeVersion @NodeOut
|
|
return
|
|
}
|
|
|
|
Use-NodeLatest @NodeOut
|
|
|
|
# Anything after the switch reverts on the way out of a failure. Without this a
|
|
# run that dies in migrations leaves the machine on the new version with nothing
|
|
# started, and the -Stop that would put it back is never reached.
|
|
try {
|
|
Assert-Docker
|
|
Start-Database
|
|
Install-IfMissing (Join-Path $RepoRoot 'backend')
|
|
Install-IfMissing (Join-Path $RepoRoot 'frontend')
|
|
Start-Backend
|
|
Start-Frontend
|
|
}
|
|
catch {
|
|
Restore-Node -Version $DefaultNodeVersion @NodeOut
|
|
throw
|
|
}
|
|
|
|
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 (also restores Node $DefaultNodeVersion)"
|
|
Write-Host ''
|