Files
redefined-designs/scripts/start-local.ps1
T
bermudalambandClaude Opus 5 06933aec75 fix(scripts): make the alias check fail closed, and correct three stale docs (#208)
The alias check was a negative match on an allowlist of English error strings, which returned True for empty output, for $null, and for "exit status 1: Access is denied." — so an alias switch producing nothing, or failing on the symlink permission error this file's own header warns about, was reported as success while the old version kept running. That is the bug #198 was filed about, narrowed rather than removed, and it also broke whenever nvm reworded an error. It is now a positive match on "Now using node v<what is actually running>".

The floor check moves ahead of the switch and reads the constant rather than the result. Where it sat, $major was always whatever NODE_VERSION says, so it validated the switch it had just made instead of the pin it exists to guard, and could never fire.

Use-NodeLatest is now Use-PinnedNode. In a change whose whole subject is that "latest" means something people do not expect, the name was an avoidable trap.

The restore default moves beside NODE_VERSION. It deliberately is not a param default: a param block runs before the dot-source, so $script:DEFAULT_NODE_VERSION is still $null there and the restore would have quietly restored nothing — leaving the machine on the pinned version, which is the exact failure the restore exists to prevent. It is resolved after the dot-source instead, and an explicit -DefaultNodeVersion still wins.

Three documents described behaviour the code no longer has: README's "both scripts run nvm use latest", run-tests.ps1's .DESCRIPTION, and project-context.md's instruction to agents. All corrected, and project-context.md now also says not to run these scripts from an agent shell, which is how this machine once ended up with no Node at all.

Part 4 of the issue is partly stale: backend/package.json already declares engines >=20.9.0. frontend now matches it. The larger question — whether local should be pinned to the Node 20 that CI and the production image actually run — is a decision rather than an oversight and is left open on the issue.

Verified by parsing all three scripts with the PowerShell AST parser, which does not execute them. They are deliberately never run from an agent shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00

311 lines
11 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.
# Resolved after NodeVersion.ps1 is dot-sourced below, not here. A param
# block runs before anything else in the script, so $script:DEFAULT_NODE_VERSION
# is still $null at this point and using it as the default would silently
# restore nothing — leaving the machine on the pinned version, which is the
# exact failure the restore exists to prevent. See #208.
[string]$DefaultNodeVersion = ''
)
$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')
# The one home for this value is NodeVersion.ps1, beside NODE_VERSION. It cannot
# be a param default (see the note there), so it is filled in here instead, and
# an explicit -DefaultNodeVersion still wins.
if (-not $DefaultNodeVersion) { $DefaultNodeVersion = $script:DEFAULT_NODE_VERSION }
# 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-PinnedNode @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 ''