From df9e534e422ccbac0e55214182899cd76aecbabb Mon Sep 17 00:00:00 2001 From: "nilesh.p" Date: Sat, 10 Oct 2026 11:29:13 +0530 Subject: [PATCH] Add app.ps1 dev lifecycle script (start/stop/restart/rebuild/status/logs) A single PowerShell entry point for the local stack: - refreshes PATH from the registry so it works in stale terminals - ensures PostgreSQL is up; starts the Go API and Angular dev server - tracks PIDs in .run/ and also sweeps the ports on stop - rebuild (-Clean/-Install), status and logs (-Follow) actions .run/ (pids, logs) is git-ignored. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + app.ps1 | 399 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 app.ps1 diff --git a/.gitignore b/.gitignore index 0e3e934..cfdb50f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ backend/coverage.out frontend/coverage/ e2e/evidence-report/ e2e/**/*.pdf + +# --- app.ps1 runtime (pids, logs) ------------------------------------------- +.run/ diff --git a/app.ps1 b/app.ps1 new file mode 100644 index 0000000..99f3a7d --- /dev/null +++ b/app.ps1 @@ -0,0 +1,399 @@ +#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 } +}