param( [switch]$Restart, [switch]$ForceKillPorts, [switch]$InstallDependencies, [switch]$InstallBrowsers, [switch]$InitDatabase, [switch]$SeedDatabase ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $RuntimeDir = Join-Path $RepoRoot ".runtime" $Services = @( @{ Name = "api" Port = 3001 Command = "npm.cmd run dev:api" ReadyUrl = "http://localhost:3001/health" ReadyText = '"ok":true' }, @{ Name = "web" Port = 3000 Command = "npm.cmd run dev:web" ReadyUrl = "http://localhost:3000" ReadyText = "" } ) function Write-Step([string]$Message) { Write-Host "==> $Message" -ForegroundColor Cyan } function Write-WarnLine([string]$Message) { Write-Host "WARN: $Message" -ForegroundColor Yellow } function Get-PidFilePath([string]$Name) { Join-Path $RuntimeDir "$Name.pid" } function Get-LogPath([string]$Name) { Join-Path $RuntimeDir "$Name.log" } function Get-ErrLogPath([string]$Name) { Join-Path $RuntimeDir "$Name.err.log" } function Get-ManagedPid([string]$Name) { $pidFile = Get-PidFilePath $Name if (-not (Test-Path $pidFile)) { return $null } $raw = Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $raw) { return $null } $parsed = 0 if ([int]::TryParse($raw, [ref]$parsed)) { return $parsed } return $null } function Test-ProcessRunning([int]$ProcessId) { try { $null = Get-Process -Id $ProcessId -ErrorAction Stop return $true } catch { return $false } } function Get-ProcessCommandLine([int]$ProcessId) { try { $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" if ($null -eq $process -or $null -eq $process.CommandLine) { return "" } return [string]$process.CommandLine } catch { return "" } } function Test-SentAIProcess([int]$ProcessId) { $commandLine = Get-ProcessCommandLine $ProcessId if (-not $commandLine) { return $false } return $commandLine -like "*SentAI*" -or $commandLine -like "*@sentai*" -or $commandLine -like "*npm.cmd run dev:api*" -or $commandLine -like "*npm.cmd run dev:web*" } function Stop-ProcessTree([int]$ProcessId) { & taskkill /PID $ProcessId /T /F | Out-Null } function Get-ListenerOwners([int]$Port) { try { return @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique) } catch { return @() } } function Test-UrlReady([string]$Url, [string]$ExpectedText) { try { $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3 if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) { return $false } if ([string]::IsNullOrWhiteSpace($ExpectedText)) { return $true } $content = [string]$response.Content return $content.Contains($ExpectedText) } catch { return $false } } function Wait-ForReady([hashtable]$Service, [int]$TimeoutSeconds = 90) { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { if (Test-UrlReady -Url $Service.ReadyUrl -ExpectedText $Service.ReadyText) { return $true } Start-Sleep -Seconds 2 } return $false } function Ensure-PortAvailable([hashtable]$Service) { [array]$owners = @(Get-ListenerOwners $Service.Port) if ($owners.Count -eq 0) { return } foreach ($owner in $owners) { if ($ForceKillPorts -or (Test-SentAIProcess $owner)) { Write-WarnLine "Stopping process $owner on port $($Service.Port)." Stop-ProcessTree $owner continue } $commandLine = Get-ProcessCommandLine $owner throw "Port $($Service.Port) is already used by process $owner. Use -ForceKillPorts if you want the script to clear it. CommandLine: $commandLine" } Start-Sleep -Seconds 2 [array]$remaining = @(Get-ListenerOwners $Service.Port) if ($remaining.Count -gt 0) { throw "Port $($Service.Port) is still occupied after cleanup." } } function Ensure-ManagedProcessState([hashtable]$Service) { $managedPid = Get-ManagedPid $Service.Name if (-not $managedPid) { return } if (-not (Test-ProcessRunning $managedPid)) { Remove-Item (Get-PidFilePath $Service.Name) -ErrorAction SilentlyContinue return } if ($Restart) { Write-WarnLine "Restart requested. Stopping existing managed $($Service.Name) process $managedPid." Stop-ProcessTree $managedPid Remove-Item (Get-PidFilePath $Service.Name) -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 return } if (Test-UrlReady -Url $Service.ReadyUrl -ExpectedText $Service.ReadyText) { Write-Step "$($Service.Name) is already running on port $($Service.Port)." $Service.SkipStart = $true return } Write-WarnLine "Managed $($Service.Name) process $managedPid exists but is not healthy. Restarting it." Stop-ProcessTree $managedPid Remove-Item (Get-PidFilePath $Service.Name) -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 } function Start-ServiceProcess([hashtable]$Service) { if ($Service.ContainsKey("SkipStart") -and $Service.SkipStart) { return } $stdout = Get-LogPath $Service.Name $stderr = Get-ErrLogPath $Service.Name Remove-Item $stdout, $stderr -ErrorAction SilentlyContinue Write-Step "Starting $($Service.Name) with '$($Service.Command)'." $process = Start-Process ` -FilePath "cmd.exe" ` -ArgumentList "/c", $Service.Command ` -WorkingDirectory $RepoRoot ` -RedirectStandardOutput $stdout ` -RedirectStandardError $stderr ` -PassThru Set-Content -Path (Get-PidFilePath $Service.Name) -Value $process.Id if (-not (Wait-ForReady -Service $Service)) { throw "$($Service.Name) did not become ready in time. Check $stdout and $stderr" } Write-Step "$($Service.Name) is ready at $($Service.ReadyUrl)." } New-Item -ItemType Directory -Path $RuntimeDir -Force | Out-Null Set-Location $RepoRoot if ($InstallDependencies) { Write-Step "Installing npm dependencies." & npm.cmd install } if ($InitDatabase) { Write-Step "Initializing database schema." & npm.cmd run db:init } if ($SeedDatabase) { Write-Step "Seeding database." & npm.cmd run db:seed } if ($InstallBrowsers) { Write-Step "Installing Playwright browsers." & npm.cmd run install:browsers } foreach ($service in $Services) { Ensure-ManagedProcessState $service Ensure-PortAvailable $service Start-ServiceProcess $service } Write-Host "" Write-Host "SentAI local environment is running." -ForegroundColor Green Write-Host " Web: http://localhost:3000" Write-Host " API: http://localhost:3001/health"