feat(scripts): a PowerShell script to start the local environment (#125) #126
@@ -10,5 +10,7 @@ playwright-report/
|
||||
test-results/
|
||||
.env
|
||||
.superpowers/
|
||||
# Logs, pids and uploads written by scripts/start-local.ps1
|
||||
.local/
|
||||
.scannerwork/
|
||||
.nyc_output/
|
||||
|
||||
@@ -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 ''
|
||||
Reference in New Issue
Block a user