#requires -Version 5.1 <# .SYNOPSIS Start, stop, restart, rebuild and inspect the TravelHub app (Go API + Angular SPA + PostgreSQL). .DESCRIPTION One script to drive the local dev stack. It refreshes PATH from the registry on every run, so it works even in a terminal opened before Go/Node were installed (a recurring gotcha on this machine). Processes are tracked by PID files under .run\, and stop also sweeps whatever is listening on the API/web ports as a fallback, so a crashed or orphaned server is still cleaned up. .PARAMETER Action start Ensure PostgreSQL is up, then launch the API and the web dev server. stop Stop the API and web dev server (PostgreSQL is left running). restart stop then start. rebuild Build the Go binary and the production Angular bundle. status Show what is running, on which ports, with which PIDs. logs Print (or follow) the API and web logs. help This help. .PARAMETER Dev start: run the API via `go run` and rely on port-based cleanup, instead of the compiled binary. The web server is always `ng serve` either way. .PARAMETER Clean rebuild: delete build output and caches (bin, dist, .angular) first. .PARAMETER Install rebuild: (re)install dependencies -- npm install for frontend/e2e and `go mod download` -- before building. .PARAMETER Migrate start / rebuild: apply database migrations (npm run db:migrate) as well. .PARAMETER Follow logs: tail the logs live instead of printing the tail once. .PARAMETER IncludeDb stop: also stop the PostgreSQL Windows service (off by default -- it is a shared service and usually wants to stay up). .EXAMPLE .\app.ps1 start .EXAMPLE .\app.ps1 rebuild -Clean -Install .EXAMPLE .\app.ps1 restart -Dev .EXAMPLE .\app.ps1 logs -Follow #> [CmdletBinding()] param( [Parameter(Position = 0)] [ValidateSet('start', 'stop', 'restart', 'rebuild', 'status', 'logs', 'help')] [string]$Action = 'help', [switch]$Dev, [switch]$Clean, [switch]$Install, [switch]$Migrate, [switch]$Follow, [switch]$IncludeDb ) $ErrorActionPreference = 'Stop' # --- configuration --------------------------------------------------------- $Root = $PSScriptRoot $Backend = Join-Path $Root 'backend' $Frontend = Join-Path $Root 'frontend' $RunDir = Join-Path $Root '.run' $ApiPort = 8080 # overridden below if backend\.env sets PORT $WebPort = 4200 $DbService = 'postgresql-x64-17' $ApiPidFile = Join-Path $RunDir 'api.pid' $WebPidFile = Join-Path $RunDir 'web.pid' $ApiLog = Join-Path $RunDir 'api.log' $ApiErrLog = Join-Path $RunDir 'api.err.log' $WebLog = Join-Path $RunDir 'web.log' $WebErrLog = Join-Path $RunDir 'web.err.log' $WebUrl = "http://localhost:$WebPort/" # --- console helpers ------------------------------------------------------- function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } function Write-Ok($msg) { Write-Host " + $msg" -ForegroundColor Green } function Write-Warn2($msg){ Write-Host " ! $msg" -ForegroundColor Yellow } function Write-Err2($msg) { Write-Host " x $msg" -ForegroundColor Red } # --- environment ----------------------------------------------------------- # Rebuild PATH from the registry so go/node/npm resolve even in a shell that # started before they were installed. Machine scope first, then User. function Sync-Path { $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') $user = [Environment]::GetEnvironmentVariable('Path', 'User') $env:Path = ($machine, $user | Where-Object { $_ }) -join ';' } # Resolve an executable, falling back to its standard Windows install path. function Resolve-Tool($name, [string[]]$fallbacks) { $cmd = Get-Command $name -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } foreach ($f in $fallbacks) { if (Test-Path $f) { return $f } } throw "$name not found on PATH. Install it, or open a fresh terminal so PATH is current." } function Get-Go { Resolve-Tool 'go.exe' @('C:\Program Files\Go\bin\go.exe') } function Get-Npm { Resolve-Tool 'npm.cmd' @('C:\Program Files\nodejs\npm.cmd') } # Read PORT from backend\.env if present, so the script tracks the real port. function Read-ApiPort { $envFile = Join-Path $Backend '.env' if (-not (Test-Path $envFile)) { return } $line = Select-String -Path $envFile -Pattern '^\s*PORT\s*=\s*(\d+)' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($line) { $script:ApiPort = [int]$line.Matches[0].Groups[1].Value } } # --- process primitives ---------------------------------------------------- function Get-PortPid($port) { try { $conns = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction Stop return ($conns | Select-Object -ExpandProperty OwningProcess -Unique) } catch { return @() } } # Kill a process and all of its descendants (npm spawns node; go run spawns the # compiled binary), so nothing is left holding the port. function Stop-Tree($procId) { if (-not $procId) { return } $children = Get-CimInstance Win32_Process -Filter "ParentProcessId = $procId" -ErrorAction SilentlyContinue foreach ($c in $children) { Stop-Tree $c.ProcessId } try { Stop-Process -Id $procId -Force -ErrorAction Stop } catch { } } function Stop-ByPidFile($file, $label) { if (Test-Path $file) { $procId = (Get-Content $file -ErrorAction SilentlyContinue | Select-Object -First 1) if ($procId) { Stop-Tree ([int]$procId) } Remove-Item $file -Force -ErrorAction SilentlyContinue } } function Stop-ByPort($port, $label) { $killed = $false foreach ($procId in (Get-PortPid $port)) { Stop-Tree ([int]$procId) $killed = $true } if ($killed) { Write-Ok "$label (port $port) stopped" } return $killed } function Wait-ForUrl($url, $timeoutSec) { $deadline = (Get-Date).AddSeconds($timeoutSec) while ((Get-Date) -lt $deadline) { try { Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 3 | Out-Null return $true } catch { Start-Sleep -Milliseconds 500 } } return $false } function Test-PortUp($port) { return ((Get-PortPid $port).Count -gt 0) } # --- database -------------------------------------------------------------- function Start-Database { $svc = Get-Service -Name $DbService -ErrorAction SilentlyContinue if (-not $svc) { Write-Warn2 "PostgreSQL service '$DbService' not found -- is PostgreSQL installed?" return } if ($svc.Status -ne 'Running') { Write-Step "Starting PostgreSQL ($DbService)" Start-Service $DbService Write-Ok 'PostgreSQL started' } else { Write-Ok 'PostgreSQL already running' } } function Invoke-Migrate { Write-Step 'Applying database migrations' $npm = Get-Npm Push-Location $Root try { & $npm run db:migrate } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { throw 'Migrations failed.' } Write-Ok 'Migrations applied' } # --- build ----------------------------------------------------------------- function Build-Backend { Write-Step 'Building Go API' $go = Get-Go Push-Location $Backend try { & $go build -o 'bin\api.exe' './cmd/api' } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { throw 'Go build failed.' } Write-Ok 'backend\bin\api.exe' } function Build-Frontend { Write-Step 'Building Angular bundle' $npm = Get-Npm Push-Location $Frontend try { & $npm run build } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { throw 'Angular build failed.' } Write-Ok 'frontend\dist' } function Install-Deps { $npm = Get-Npm $go = Get-Go Write-Step 'Installing frontend dependencies' Push-Location $Frontend; try { & $npm install } finally { Pop-Location } if (Test-Path (Join-Path $Root 'e2e')) { Write-Step 'Installing e2e dependencies' Push-Location (Join-Path $Root 'e2e'); try { & $npm install } finally { Pop-Location } } Write-Step 'Downloading Go modules' Push-Location $Backend; try { & $go mod download } finally { Pop-Location } Write-Ok 'Dependencies installed' } function Invoke-Clean { Write-Step 'Cleaning build output' foreach ($p in @( (Join-Path $Backend 'bin'), (Join-Path $Frontend 'dist'), (Join-Path $Frontend '.angular'))) { if (Test-Path $p) { Remove-Item $p -Recurse -Force; Write-Ok "removed $p" } } } # --- start / stop ---------------------------------------------------------- function Start-Api { if (Test-PortUp $ApiPort) { Write-Warn2 "API already listening on port $ApiPort -- skipping"; return } New-Item -ItemType Directory -Force -Path $RunDir | Out-Null $go = Get-Go if ($Dev) { Write-Step "Starting API (go run) on port $ApiPort" $proc = Start-Process -FilePath $go -ArgumentList 'run', './cmd/api' ` -WorkingDirectory $Backend -WindowStyle Hidden -PassThru ` -RedirectStandardOutput $ApiLog -RedirectStandardError $ApiErrLog } else { $api = Join-Path $Backend 'bin\api.exe' if (-not (Test-Path $api)) { Write-Warn2 'No built binary; building first.'; Build-Backend } Write-Step "Starting API on port $ApiPort" $proc = Start-Process -FilePath $api ` -WorkingDirectory $Backend -WindowStyle Hidden -PassThru ` -RedirectStandardOutput $ApiLog -RedirectStandardError $ApiErrLog } $proc.Id | Out-File -FilePath $ApiPidFile -Encoding ascii if (Wait-ForUrl "http://localhost:$ApiPort/api/health" 60) { Write-Ok "API healthy (PID $($proc.Id)) -> http://localhost:$ApiPort" } else { Write-Err2 "API did not become healthy in time. Check: .\app.ps1 logs" } } function Start-Web { if (Test-PortUp $WebPort) { Write-Warn2 "Web already listening on port $WebPort -- skipping"; return } New-Item -ItemType Directory -Force -Path $RunDir | Out-Null $npm = Get-Npm Write-Step "Starting web dev server on port $WebPort" $proc = Start-Process -FilePath $npm -ArgumentList 'start' ` -WorkingDirectory $Frontend -WindowStyle Hidden -PassThru ` -RedirectStandardOutput $WebLog -RedirectStandardError $WebErrLog $proc.Id | Out-File -FilePath $WebPidFile -Encoding ascii if (Wait-ForUrl $WebUrl 180) { Write-Ok "Web ready (PID $($proc.Id)) -> $WebUrl" } else { Write-Err2 "Web server did not come up in time. Check: .\app.ps1 logs" } } function Start-App { Start-Database Start-Api Start-Web Write-Host '' Write-Host "TravelHub is up. Open $WebUrl" -ForegroundColor Green Write-Host 'Sign in with any demo account (password demo1234), e.g. agent@skyline.test / skyline.' } function Stop-App { Write-Step 'Stopping web dev server' Stop-ByPidFile $WebPidFile 'web' [void](Stop-ByPort $WebPort 'web') Write-Step 'Stopping API' Stop-ByPidFile $ApiPidFile 'api' [void](Stop-ByPort $ApiPort 'api') if ($IncludeDb) { $svc = Get-Service -Name $DbService -ErrorAction SilentlyContinue if ($svc -and $svc.Status -eq 'Running') { Write-Step 'Stopping PostgreSQL' Stop-Service $DbService Write-Ok 'PostgreSQL stopped' } } Write-Ok 'Stopped' } # --- status / logs --------------------------------------------------------- function Show-Status { Write-Step 'TravelHub status' $svc = Get-Service -Name $DbService -ErrorAction SilentlyContinue $dbState = if ($svc) { $svc.Status } else { 'not installed' } function PortLine($label, $port) { $pids = Get-PortPid $port if ($pids) { "{0,-18} UP port {1} PID {2}" -f $label, $port, ($pids -join ',') } else { "{0,-18} down port {1}" -f $label, $port } } Write-Host (" {0,-18} {1}" -f 'PostgreSQL', $dbState) Write-Host (" " + (PortLine 'API' $ApiPort)) Write-Host (" " + (PortLine 'Web (dev server)' $WebPort)) if (Test-PortUp $ApiPort) { try { $h = Invoke-RestMethod "http://localhost:$ApiPort/api/health" -TimeoutSec 3 Write-Host (" {0,-18} {1}" -f 'API health', $h.status) } catch { Write-Host (" {0,-18} {1}" -f 'API health', 'unreachable') } } } function Show-Logs { $logs = @($ApiLog, $ApiErrLog, $WebLog, $WebErrLog) | Where-Object { Test-Path $_ } if (-not $logs) { Write-Warn2 'No logs yet -- start the app first.'; return } if ($Follow) { Write-Step 'Following logs (Ctrl+C to stop)' Get-Content $logs -Tail 20 -Wait } else { foreach ($l in $logs) { Write-Step (Split-Path $l -Leaf) Get-Content $l -Tail 25 -ErrorAction SilentlyContinue Write-Host '' } } } function Show-Help { Get-Help $PSCommandPath -Detailed } # --- dispatch -------------------------------------------------------------- Sync-Path Read-ApiPort switch ($Action) { 'start' { if ($Migrate) { Start-Database; Invoke-Migrate } Start-App } 'stop' { Stop-App } 'restart' { Stop-App; Start-Sleep -Seconds 1; Start-App } 'rebuild' { if ($Clean) { Invoke-Clean } if ($Install) { Install-Deps } Build-Backend Build-Frontend if ($Migrate) { Start-Database; Invoke-Migrate } Write-Host '' Write-Ok 'Rebuild complete. Run ".\app.ps1 restart" to serve the new build.' } 'status' { Show-Status } 'logs' { Show-Logs } default { Show-Help } }