#!/usr/bin/env pwsh # Unified launcher for dev / test. APP_ENV in the root .env decides what to run. # development -> uvicorn --reload (backend) + vite (frontend), native, two windows # test -> docker compose prod-clone (port 8080) # production -> NOT started by launcher (prod is separate: docker compose up -d on Pi) # # LOCAL_PUBLIC=vps (dev only) opens an SSH tunnel to the VPS, exposing dev at # https://forbidden-stars.ru. test/prod expose themselves via an in-container tunnel. # # Run: .\run.ps1 # If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # NOTE: keep this file ASCII-only. Windows PowerShell 5.1 reads BOM-less .ps1 in the # system ANSI codepage, so non-ASCII (Cyrillic / box chars) breaks the parser. $ErrorActionPreference = "Stop" $root = $PSScriptRoot $envFile = Join-Path $root ".env" if (-not (Test-Path $envFile)) { Write-Host "No .env in repo root. Create it: Copy-Item .env.example .env" -ForegroundColor Red exit 1 } # Read a key from .env (last assignment wins; comments ignored). function Get-EnvVal([string]$name, [string]$default) { $val = $default foreach ($line in Get-Content $envFile) { if ($line -match "^\s*$name\s*=\s*([^#\s]+)") { $val = $matches[1].Trim() } } return $val } $appEnv = (Get-EnvVal "APP_ENV" "development").ToLower() $localPublic = (Get-EnvVal "LOCAL_PUBLIC" "local").ToLower() $vpsHost = Get-EnvVal "VPS_TUNNEL_HOST" "" $vpsUser = Get-EnvVal "VPS_TUNNEL_USER" "tunnel" $vpsPort = Get-EnvVal "VPS_TUNNEL_PORT" "9001" Write-Host "APP_ENV = $appEnv LOCAL_PUBLIC = $localPublic" -ForegroundColor Cyan # Open SSH reverse tunnel VPS:$vpsPort -> localhost:$localPort (in a separate window). function Start-VpsTunnel([int]$localPort) { if (-not $vpsHost) { Write-Host "LOCAL_PUBLIC=vps but VPS_TUNNEL_HOST is empty in .env - tunnel skipped." -ForegroundColor Red return } Write-Host "SSH tunnel: forbidden-stars.ru (VPS:$vpsPort) -> localhost:$localPort ..." -ForegroundColor Green $ssh = "ssh -N -R ${vpsPort}:localhost:${localPort} " + "-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes " + "${vpsUser}@${vpsHost}" Start-Process powershell -ArgumentList @( "-NoExit", "-Command", "Write-Host 'SSH tunnel to forbidden-stars.ru. Close this window to stop.' -ForegroundColor Cyan; $ssh" ) } # Kill any leftover tunnel from a previous run, so LOCAL_PUBLIC is authoritative each launch # (the tunnel lives in its own window and would otherwise keep the domain served). function Stop-VpsTunnel { Get-CimInstance Win32_Process -Filter "Name='ssh.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "-R\s+${vpsPort}:localhost" } | ForEach-Object { Write-Host "Closing leftover tunnel (pid $($_.ProcessId))..." -ForegroundColor DarkYellow Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } # Start from a clean tunnel state; a fresh tunnel is opened only if LOCAL_PUBLIC=vps below. Stop-VpsTunnel switch ($appEnv) { "development" { $backend = Join-Path $root "backend" $frontend = Join-Path $root "frontend" $py = Join-Path $backend ".venv\Scripts\python.exe" if (-not (Test-Path $py)) { Write-Host "venv not found: $py" -ForegroundColor Red Write-Host "First: cd backend; python -m venv .venv; .\.venv\Scripts\Activate.ps1; pip install -e `".[dev]`"" -ForegroundColor Yellow exit 1 } if (-not (Test-Path (Join-Path $frontend "node_modules"))) { Write-Host "node_modules not found. First: cd frontend; npm install" -ForegroundColor Red exit 1 } # Schema first: a branch may add a migration, and uvicorn would otherwise start # on the old schema and fail with 500 on the first hit of a new table. # Start-Process, not "& python ... 2> file": alembic logs to stderr, and in # Windows PowerShell 5.1 ANY stderr redirection of a native exe turns each line # into a NativeCommandError - which is terminating under $ErrorActionPreference # = "Stop" set at the top of this script, so the launcher died right here. Write-Host "Applying migrations (alembic upgrade head)..." -ForegroundColor Green $migOut = [System.IO.Path]::GetTempFileName() $migErr = [System.IO.Path]::GetTempFileName() $mig = Start-Process -FilePath $py ` -ArgumentList "-m", "alembic", "upgrade", "head" ` -WorkingDirectory $backend -NoNewWindow -Wait -PassThru ` -RedirectStandardOutput $migOut -RedirectStandardError $migErr $migCode = $mig.ExitCode $migText = ((Get-Content $migErr -Raw), (Get-Content $migOut -Raw)) -join "`n" Remove-Item $migOut -Force -ErrorAction SilentlyContinue Remove-Item $migErr -Force -ErrorAction SilentlyContinue if ($migCode -ne 0) { Write-Host $migText if ($migText -match "locate revision") { # The DB carries a migration this branch does not have (switched back from # a feature branch). Extra tables do not bother the older code - go on. Write-Host "DB is newer than this branch - migrations skipped." -ForegroundColor Yellow } else { Write-Host "Migrations failed - not starting. Fix the error above and retry." -ForegroundColor Red exit 1 } } Write-Host "Starting backend (uvicorn --reload) and frontend (vite) in separate windows..." -ForegroundColor Green # --timeout-graceful-shutdown: an open tab keeps the SSE stream /api/events forever, # and on reload the old process waits for ALL connections to close - with no limit # the reload never finishes and the site hangs on loading with nothing in the log. Start-Process powershell -ArgumentList @( "-NoExit", "-Command", "Set-Location '$backend'; & '$py' -m uvicorn app.main:app --reload --timeout-graceful-shutdown 2" ) Start-Process powershell -ArgumentList @( "-NoExit", "-Command", "Set-Location '$frontend'; npm run dev" ) Write-Host "" Write-Host " Backend: http://127.0.0.1:8000 (Swagger: /api/docs)" -ForegroundColor Green Write-Host " Frontend: http://127.0.0.1:5173" -ForegroundColor Green if ($localPublic -eq "vps") { Start-VpsTunnel 5173 Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green } } "test" { $compose = Join-Path $root "docker-compose.test.yml" docker info --format '{{.ServerVersion}}' *> $null if ($LASTEXITCODE -ne 0) { Write-Host "Docker daemon is not reachable. Start Docker Desktop, wait until it is running, then re-run .\run.ps1" -ForegroundColor Red exit 1 } $keyFile = Join-Path $root "deploy\tunnel\id_tunnel" if (-not (Test-Path $keyFile -PathType Leaf)) { if (Test-Path $keyFile -PathType Container) { Write-Host "deploy\tunnel\id_tunnel is a DIRECTORY - Docker auto-created it because the key file was missing." -ForegroundColor Red Write-Host "Remove it first: Remove-Item -Recurse -Force deploy\tunnel\id_tunnel" -ForegroundColor Yellow } else { Write-Host "No SSH key at deploy\tunnel\id_tunnel (the tunnel container needs it)." -ForegroundColor Red } Write-Host "Add your VPS-authorized key: Copy-Item `$env:USERPROFILE\.ssh\id_ed25519 deploy\tunnel\id_tunnel" -ForegroundColor Yellow exit 1 } Write-Host "Building and starting prod-clone in Docker (app + in-container tunnel)..." -ForegroundColor Green docker compose -f $compose up --build -d if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "" Write-Host " Public: https://forbidden-stars.ru (Swagger: /api/docs)" -ForegroundColor Green Write-Host " (no host port - reachable only via the domain; tunnel runs inside compose)" Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f" Write-Host " Stop: docker compose -f docker-compose.test.yml down -v" } "production" { Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow Write-Host "Deploy on Pi (from main branch): docker compose up -d --build" exit 1 } default { Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | test | production." -ForegroundColor Red exit 1 } }