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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9d2b7425b1
commit
df9e534e42
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user