<# .SYNOPSIS Vignette 로컬 개발 스택을 깔끔하게 (재)기동한다: 엔진 게이트웨이(9099) + API(8000) + 웹(5173). .DESCRIPTION - 기존(고아 포함) 프로세스를 커맨드라인 기준으로 정확히 정리한 뒤 새로 띄운다. API는 Windows reloader 고아 프로세스/포트 잔류를 피하려고 단일 프로세스로 띄운다. 게이트웨이는 no-reload 유지(상주 claude -p 세션 보존; Windows에선 reload 시 자식 claude 고아화). 웹(vite)은 npm run dev 기본 HMR. - API는 DB 미가용 시 in-memory degraded로 기동된다(Postgres 불필요). dev-login + seed 페르소나 활성. - 로그는 .devlogs/ 에 남긴다. 종료는 scripts/dev-down.ps1. .PARAMETER NoGateway 엔진 게이트웨이를 띄우지 않는다(AI 턴 생성 불가, UI만 테스트 시). .PARAMETER NoWeb vite 웹 서버를 띄우지 않는다(API만 필요할 때). .PARAMETER NoDb Docker Postgres 보장을 건너뛰고 in-memory degraded 기동을 허용한다. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 #> param( [int]$ApiPort = 8000, [switch]$NoGateway, [switch]$NoWeb, [switch]$NoDb ) $ErrorActionPreference = 'Stop' $repo = Split-Path -Parent $PSScriptRoot $api = Join-Path $repo 'apps\api' $web = Join-Path $repo 'apps\web' $logs = Join-Path $repo '.devlogs' New-Item -ItemType Directory -Force -Path $logs | Out-Null # uvicorn 이 설치된 python 을 해석한다(시스템에 3.11/3.14 등 복수 python 공존 — 'python' 별칭이 # uvicorn 없는 인터프리터를 가리킬 수 있다). 후보를 순회해 import uvicorn 성공하는 것을 고른다. $pyCandidates = @( (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python311\python.exe'), (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe'), 'py', 'python' ) $Python = $null foreach ($c in $pyCandidates) { $exe = $c; $pre = @() if ($c -eq 'py') { $pre = @('-3') } try { & $exe @pre '-c' 'import uvicorn' 2>$null if ($LASTEXITCODE -eq 0) { $Python = $exe; $PyPre = $pre; break } } catch {} } if (-not $Python) { Write-Host 'ERROR: uvicorn 설치된 python 을 못 찾음 (pip install -r apps/api/requirements.txt)'; exit 1 } Write-Host ("python: {0} {1}" -f $Python, ($PyPre -join ' ')) function Stop-Stale([string]$pattern, [string]$label) { Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe') -and $_.CommandLine -and $_.CommandLine -match $pattern } | ForEach-Object { Write-Host (" stop {0,-8} PID {1}" -f $label, $_.ProcessId) Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } function Stop-ListenerByPort([int]$port, [string]$label) { Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | Where-Object { $_ -and $_ -ne 0 } | ForEach-Object { Write-Host (" stop {0,-8} PID {1} (:${port})" -f $label, $_) Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } function Wait-Health([string]$url, [string]$label, [int]$timeoutSec = 40) { $deadline = (Get-Date).AddSeconds($timeoutSec) while ((Get-Date) -lt $deadline) { try { $r = Invoke-WebRequest -Uri $url -TimeoutSec 4 -UseBasicParsing -ErrorAction Stop if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { Write-Host " OK $label ($url)"; return $true } } catch { Start-Sleep -Milliseconds 600 } } Write-Host " WARN $label 미응답 ($url) — .devlogs 로그 확인"; return $false } function Read-EnvFileValue([string]$path, [string]$key) { if (!(Test-Path -LiteralPath $path)) { return $null } $pattern = ('^\s*{0}\s*=' -f [regex]::Escape($key)) $line = Get-Content -LiteralPath $path -Encoding utf8 | Where-Object { $_ -match $pattern } | Select-Object -First 1 if (-not $line) { return $null } $value = ($line -replace $pattern, '').Trim() $value = $value.Trim('"') $value = $value.Trim("'") return $value } function Warn-ComposeEnvReadiness { $composeEnv = Join-Path $repo 'infra\.env' if (!(Test-Path -LiteralPath $composeEnv)) { Write-Host ' INFO infra\.env 없음 - docker compose 전체 스택은 .env 작성 후 사용.' return } $required = @( 'POSTGRES_PASSWORD', 'APP_DB_PASSWORD', 'OPENAI_API_KEY', 'SESSION_SECRET', 'OAUTH_GOOGLE_CLIENT_ID', 'OAUTH_GOOGLE_CLIENT_SECRET' ) $missing = @() foreach ($key in $required) { $value = Read-EnvFileValue $composeEnv $key if ([string]::IsNullOrWhiteSpace($value)) { $missing += $key } } if ($missing.Count -gt 0) { Write-Host (' WARN infra\.env 필수값 누락: {0} - compose api/proxy 기동 전 보강 필요.' -f ($missing -join ', ')) } } function Test-DevDbRoleSafety([string]$ownerUser, [string]$dbName) { if ([string]::IsNullOrWhiteSpace($ownerUser) -or [string]::IsNullOrWhiteSpace($dbName)) { return } $sql = "SELECT rolname || ':' || rolsuper || ':' || rolbypassrls FROM pg_roles WHERE rolname IN ('vignette_app','vignette_owner') ORDER BY rolname;" $rows = & docker exec vignette-dev-db psql -U $ownerUser -d $dbName -At -c $sql 2>$null if ($LASTEXITCODE -ne 0) { Write-Host ' WARN db role 점검 실패 - psql 접속/권한 확인 필요.' return } foreach ($row in $rows) { $parts = "$row".Split(':') if ($parts.Count -lt 3) { continue } if ($parts[0] -eq 'vignette_app' -and ($parts[1] -eq 't' -or $parts[2] -eq 't')) { Write-Host ' WARN vignette_app role 이 SUPERUSER/BYPASSRLS 상태 - RLS 검증이 무의미함. 컨테이너를 owner/app 분리로 재생성 필요.' } } } function Inspect-DevDbContainer { $healthMode = & docker inspect -f '{{if .Config.Healthcheck}}health{{else}}none{{end}}' vignette-dev-db 2>$null if ($LASTEXITCODE -eq 0 -and "$healthMode" -eq 'none') { Write-Host ' WARN vignette-dev-db healthcheck 없음 - 기존 컨테이너라면 재생성 시 healthcheck가 붙음.' } $ownerUser = & docker exec vignette-dev-db printenv POSTGRES_USER 2>$null if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($ownerUser)) { $ownerUser = 'vignette_owner' } $dbName = & docker exec vignette-dev-db printenv POSTGRES_DB 2>$null if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($dbName)) { $dbName = 'vignette' } & docker exec vignette-dev-db pg_isready -U $ownerUser 1>$null 2>$null if ($LASTEXITCODE -eq 0) { Write-Host ' OK db (vignette-dev-db 준비됨)' } else { Write-Host ' WARN db 컨테이너는 있으나 pg_isready 실패' } Test-DevDbRoleSafety "$ownerUser" "$dbName" } function Ensure-DevDb { if (!(Get-Command docker -ErrorAction SilentlyContinue)) { Write-Host ' WARN docker CLI 없음 - DB 없이 degraded(UI 세션 시작 제한).' return } if (!(Test-Path -LiteralPath '\\.\pipe\dockerDesktopLinuxEngine')) { Write-Host ' WARN Docker Desktop Linux engine 미실행 - DB 없이 degraded(UI 세션 시작 제한).' return } docker ps 1>$null 2>$null if ($LASTEXITCODE -ne 0) { Write-Host ' WARN Docker 데몬 미응답 - DB 없이 degraded(UI 세션 시작 제한). Docker Desktop 실행 필요.' return } $running = docker ps --filter name=vignette-dev-db --format '{{.Names}}' 2>$null if ($running -match 'vignette-dev-db') { Inspect-DevDbContainer return } docker rm -f vignette-dev-db 1>$null 2>$null $apiEnv = Join-Path $api '.env' $dbUrl = Read-EnvFileValue $apiEnv 'DATABASE_URL' if ($dbUrl -notmatch 'postgresql://([^:]+):([^@]+)@[^:]+:([0-9]+)/(\S+)') { Write-Host ' WARN DATABASE_URL 파싱 실패 - DB 스킵' return } $u = $Matches[1] $p = $Matches[2] $port = $Matches[3] $db = $Matches[4] $ownerUser = 'vignette_owner' $ownerPassword = $p $appUser = $u $appPassword = $p if ($appUser -eq $ownerUser) { Write-Host ' WARN DATABASE_URL 이 owner role 을 사용 중 - RLS 검증을 위해 앱 전용 role URL 권장.' } $initPath = Join-Path $repo 'infra\db\init' $dockerArgs = @( 'run', '-d', '--name', 'vignette-dev-db', '-p', ('{0}:5432' -f $port), '--health-cmd', ('pg_isready -U {0}' -f $ownerUser), '--health-interval', '10s', '--health-timeout', '5s', '--health-retries', '5', '-e', ('POSTGRES_USER={0}' -f $ownerUser), '-e', ('POSTGRES_PASSWORD={0}' -f $ownerPassword), '-e', ('POSTGRES_DB={0}' -f $db), '-e', ('APP_DB_USER={0}' -f $appUser), '-e', ('APP_DB_PASSWORD={0}' -f $appPassword), '-v', ('{0}:/docker-entrypoint-initdb.d:ro' -f $initPath), 'pgvector/pgvector:pg16' ) & docker @dockerArgs 1>$null for ($i = 0; $i -lt 24; $i++) { Start-Sleep -Seconds 2 docker exec vignette-dev-db pg_isready -U $ownerUser 1>$null 2>$null if ($LASTEXITCODE -eq 0) { Write-Host ' OK db (Postgres 준비됨, init 스키마 적용)' Test-DevDbRoleSafety $ownerUser $db Start-Sleep -Seconds 1 return } } Write-Host ' WARN db 준비 타임아웃' } Write-Host '[1/4] 기존 스택 정리...' Stop-Stale 'engine_gateway\.gateway' 'gateway' Stop-Stale 'app\.main:app' 'api' Stop-Stale 'vite' 'web' Stop-ListenerByPort 9099 'gateway' Stop-ListenerByPort $ApiPort 'api' Stop-ListenerByPort 5173 'web' Start-Sleep -Seconds 2 if (-not $NoDb) { Write-Host '[DB] Postgres(pgvector) 보장...' Warn-ComposeEnvReadiness Ensure-DevDb } if (-not $NoGateway) { Write-Host '[2/4] 엔진 게이트웨이 :9099 (claude_cli)...' $gatewayArgs = @($PyPre) + @('-m', 'uvicorn', 'engine_gateway.gateway:app', '--host', '127.0.0.1', '--port', '9099') Start-Process -FilePath $Python -ArgumentList $gatewayArgs -WorkingDirectory $api -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logs 'gateway.out.log') -RedirectStandardError (Join-Path $logs 'gateway.err.log') } else { Write-Host '[2/4] (게이트웨이 건너뜀)' } Write-Host ('[3/4] API :{0} (단일 프로세스, degraded in-memory OK, seed 페르소나)...' -f $ApiPort) $env:AUTO_SEED_PERSONAS = 'true' $env:ALLOW_SEED_PERSONA_FALLBACK = 'true' $env:VITE_API_PROXY_TARGET = "http://127.0.0.1:$ApiPort" $apiArgs = @($PyPre) + @('-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', "$ApiPort") Start-Process -FilePath $Python -ArgumentList $apiArgs -WorkingDirectory $api -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logs 'api.out.log') -RedirectStandardError (Join-Path $logs 'api.err.log') if (-not $NoWeb) { Write-Host ('[4/4] 웹 vite :5173... API proxy {0}' -f $env:VITE_API_PROXY_TARGET) Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', 'npm run dev -- --host 127.0.0.1 --port 5173') -WorkingDirectory $web -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logs 'web.out.log') -RedirectStandardError (Join-Path $logs 'web.err.log') } else { Write-Host '[4/4] (웹 건너뜀)' } Start-Sleep -Seconds 3 Write-Host '' Write-Host '헬스 체크:' if (-not $NoGateway) { Wait-Health 'http://127.0.0.1:9099/health' 'gateway' | Out-Null } Wait-Health "http://127.0.0.1:$ApiPort/health" 'api' | Out-Null if (-not $NoWeb) { Wait-Health 'http://localhost:5173/' 'web' | Out-Null } Write-Host '' Write-Host '준비 완료. 진입점: http://localhost:5173 (로그인 페이지에서 dev-login)' Write-Host '종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1'