start_local_stack.ps1 8.93 KB
[CmdletBinding()]
param(
    [string]$EnvFile,
    [switch]$Bootstrap,
    [switch]$BuildFrontend,
    [switch]$SkipPlaywright,
    [string]$FrontendHost = "127.0.0.1",
    [int]$FrontendPort = 9527
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$BackendPort = 5000
$FrontendUrl = "http://${FrontendHost}:${FrontendPort}"
$BackendUrl = "http://127.0.0.1:${BackendPort}"

Set-Location $RepoRoot

function Write-Step {
    param([string]$Message)
    Write-Host "[BettaFish] $Message" -ForegroundColor Cyan
}

function Get-PythonCommand {
    $python = Get-Command python -ErrorAction SilentlyContinue
    if ($null -eq $python) {
        throw "python was not found in PATH. Install Python and try again."
    }

    return $python.Source
}

function Get-NpmCommand {
    $npm = Get-Command npm -ErrorAction SilentlyContinue
    if ($null -eq $npm) {
        throw "npm was not found in PATH. Install Node.js and try again."
    }

    return $npm.Source
}

function Invoke-External {
    param(
        [string]$Command,
        [string[]]$Arguments
    )

    & $Command @Arguments
    $exitCode = $LASTEXITCODE
    if ($exitCode -ne 0) {
        $rendered = @($Command) + $Arguments
        throw "Command failed with exit code ${exitCode}: $($rendered -join ' ')"
    }
}

function Resolve-EnvFilePath {
    param([string]$ExplicitEnvFile)

    if ($ExplicitEnvFile) {
        $resolved = Resolve-Path -LiteralPath $ExplicitEnvFile -ErrorAction Stop
        return $resolved.Path
    }

    $localEnv = Join-Path $RepoRoot ".env.local"
    $localExample = Join-Path $RepoRoot ".env.local.example"
    $fallbackEnv = Join-Path $RepoRoot ".env"

    if (Test-Path -LiteralPath $localEnv) {
        return $localEnv
    }

    if (Test-Path -LiteralPath $localExample) {
        Copy-Item -LiteralPath $localExample -Destination $localEnv
        Write-Step "Created .env.local from .env.local.example."
        Write-Warning "Review .env.local before using research flows that depend on real model or database settings."
        return $localEnv
    }

    if (Test-Path -LiteralPath $fallbackEnv) {
        Write-Warning ".env.local was not found. Falling back to .env."
        return $fallbackEnv
    }

    throw "No env file was found. Expected .env.local, .env.local.example, or .env."
}

function Test-LocalRuntimeDependencies {
    param([string]$PythonCommand)

    $probe = @(
        "-c",
        "import flask, flask_socketio, streamlit, loguru, pydantic_settings, requests"
    )

    & $PythonCommand @probe *> $null
    return ($LASTEXITCODE -eq 0)
}

function Ensure-Bootstrap {
    param([string]$PythonCommand)

    $nodeModulesPath = Join-Path $RepoRoot "apps\web_ui\node_modules"
    $needFrontendInstall = -not (Test-Path -LiteralPath $nodeModulesPath)
    $needBootstrap = $Bootstrap.IsPresent -or $needFrontendInstall -or -not (Test-LocalRuntimeDependencies -PythonCommand $PythonCommand)

    if (-not $needBootstrap) {
        return
    }

    Write-Step "Running bootstrap_local because dependencies or frontend packages are missing."

    $arguments = @(
        "-X", "utf8",
        "-m", "scripts.dev.bootstrap_local",
        "--skip-pip-tools-upgrade"
    )

    if (-not $needFrontendInstall) {
        $arguments += "--skip-frontend"
    }

    if ($SkipPlaywright) {
        $arguments += "--skip-playwright"
    }

    Invoke-External -Command $PythonCommand -Arguments $arguments
}

function Ensure-PostgreSqlService {
    $services = @(
        Get-Service | Where-Object {
            $_.Name -match "postgres" -or $_.DisplayName -match "PostgreSQL"
        }
    )

    if ($services.Count -eq 0) {
        return
    }

    foreach ($service in $services) {
        if ($service.Status -eq "Running") {
            continue
        }

        try {
            Write-Step "Trying to start PostgreSQL service $($service.Name)."
            Start-Service -Name $service.Name
        }
        catch {
            Write-Warning "Failed to start PostgreSQL service $($service.Name): $($_.Exception.Message)"
        }
    }
}

function Get-ListeningConnections {
    param([int]$Port)

    try {
        return @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop)
    }
    catch {
        return @()
    }
}

function Get-ProcessCommandLine {
    param([int]$ProcessId)

    try {
        return (Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId").CommandLine
    }
    catch {
        return ""
    }
}

function Test-ManagedBackend {
    param([string]$CommandLine)

    return (
        $CommandLine -match "apps\.web_api" -or
        $CommandLine -match "scripts\.dev\.start_local" -or
        $CommandLine -match "app\.py"
    )
}

function Test-ManagedFrontend {
    param([string]$CommandLine)

    return (
        $CommandLine -match "vite" -or
        $CommandLine -match "apps\\web_ui" -or
        $CommandLine -match "apps/web_ui"
    )
}

function Get-PortOwner {
    param(
        [int]$Port,
        [scriptblock]$Matcher
    )

    $listeners = @(Get-ListeningConnections -Port $Port)
    if ($listeners.Count -eq 0) {
        return $null
    }

    $owner = $listeners[0].OwningProcess
    $commandLine = Get-ProcessCommandLine -ProcessId $owner

    return [pscustomobject]@{
        Port = $Port
        Pid = $owner
        CommandLine = $commandLine
        Managed = (& $Matcher $commandLine)
    }
}

try {
    Write-Step "Repo root: $RepoRoot"

    $pythonCommand = Get-PythonCommand
    $npmCommand = Get-NpmCommand
    $resolvedEnvFile = Resolve-EnvFilePath -ExplicitEnvFile $EnvFile
    $frontendMode = if ($BuildFrontend.IsPresent) { "build" } else { "dev" }

    Write-Step "Using env file: $resolvedEnvFile"
    Write-Step "Frontend mode: $frontendMode"

    Ensure-Bootstrap -PythonCommand $pythonCommand
    Ensure-PostgreSqlService

    $backendOwner = Get-PortOwner -Port $BackendPort -Matcher ${function:Test-ManagedBackend}
    if ($null -ne $backendOwner -and -not $backendOwner.Managed) {
        throw "Port $BackendPort is already in use by another process. PID=$($backendOwner.Pid) CommandLine=$($backendOwner.CommandLine)"
    }

    if ($frontendMode -eq "build") {
        if ($null -ne $backendOwner) {
            Write-Step "BettaFish backend is already running."
            Write-Host "Frontend: $BackendUrl" -ForegroundColor Green
            Write-Host "Backend API: $BackendUrl" -ForegroundColor Green
            Write-Host "PID: $($backendOwner.Pid)" -ForegroundColor Green
            exit 0
        }

        $startArguments = @(
            "-X", "utf8",
            "-m", "scripts.dev.start_local",
            "--env-file", $resolvedEnvFile,
            "--frontend-mode", "build"
        )

        Write-Step "Starting backend with deployment-compatible built frontend assets."
        Invoke-External -Command $pythonCommand -Arguments $startArguments
        exit 0
    }

    $frontendOwner = Get-PortOwner -Port $FrontendPort -Matcher ${function:Test-ManagedFrontend}
    if ($null -ne $frontendOwner -and -not $frontendOwner.Managed) {
        throw "Port $FrontendPort is already in use by another process. PID=$($frontendOwner.Pid) CommandLine=$($frontendOwner.CommandLine)"
    }

    if (($null -ne $backendOwner) -and ($null -ne $frontendOwner)) {
        Write-Step "BettaFish local dev stack is already running."
        Write-Host "Frontend: $FrontendUrl" -ForegroundColor Green
        Write-Host "Backend API: $BackendUrl" -ForegroundColor Green
        Write-Host "Backend PID: $($backendOwner.Pid)" -ForegroundColor Green
        Write-Host "Frontend PID: $($frontendOwner.Pid)" -ForegroundColor Green
        exit 0
    }

    if (($null -ne $backendOwner) -and ($null -eq $frontendOwner)) {
        Write-Step "Backend is already running. Starting only the Vite dev server."
        $frontendArguments = @(
            "--prefix", "apps/web_ui",
            "run", "dev",
            "--",
            "--host", $FrontendHost,
            "--port", [string]$FrontendPort,
            "--strictPort"
        )
        Invoke-External -Command $npmCommand -Arguments $frontendArguments
        exit 0
    }

    if (($null -eq $backendOwner) -and ($null -ne $frontendOwner)) {
        Write-Step "Frontend dev server is already running. Starting only the backend API."
        $backendArguments = @(
            "-X", "utf8",
            "-m", "scripts.dev.start_local",
            "--env-file", $resolvedEnvFile,
            "--frontend-mode", "skip"
        )
        Invoke-External -Command $pythonCommand -Arguments $backendArguments
        exit 0
    }

    $startArguments = @(
        "-X", "utf8",
        "-m", "scripts.dev.start_local",
        "--env-file", $resolvedEnvFile,
        "--frontend-mode", "dev",
        "--frontend-host", $FrontendHost,
        "--frontend-port", [string]$FrontendPort
    )

    Write-Step "Starting BettaFish local dev stack."
    Invoke-External -Command $pythonCommand -Arguments $startArguments
}
catch {
    Write-Error $_.Exception.Message
    exit 1
}