diff --git a/.gitignore b/.gitignore index a7f8048..abf3d47 100755 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,7 @@ playwright-report/ test-results/ .env .superpowers/ +# Logs, pids and uploads written by scripts/start-local.ps1 +.local/ .scannerwork/ .nyc_output/ diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 new file mode 100644 index 0000000..6aac5dd --- /dev/null +++ b/scripts/start-local.ps1 @@ -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 ''