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