param( [ValidateSet('start', 'stop', 'status', 'frontend', 'php', 'composer', 'npm', 'test', 'migrate')] [string]$Action = 'status', [Parameter(ValueFromRemainingArguments = $true)] [string[]]$ToolArguments ) $ErrorActionPreference = 'Stop' $sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' } $sgfTools = 'C:\laragon\tools\sgf-tempo' $sgfData = 'C:\laragon\data\sgf-tempo' $sgfCluster = Join-Path $sgfData 'postgres-17' $sgfPg = 'C:\Program Files\PostgreSQL\17\bin' $sgfPhp = "$sgfTools\php-8.4.27\php.exe" $sgfNode = "$sgfTools\node-v24.22.1-win-arm64\node.exe" $sgfComposer = "$sgfTools\composer-2.10.3\composer.phar" $sgfNpmCli = "$sgfTools\node-v24.22.1-win-arm64\node_modules\npm\bin\npm-cli.js" $sgfRun = Join-Path $sgfData 'run' $env:PATH = "$sgfTools\php-8.4.27;$sgfTools\node-v24.22.1-win-arm64;" + $env:PATH $env:COMPOSER_HOME = "$sgfTools\composer-home" $env:COMPOSER_CACHE_DIR = "$sgfTools\composer-cache" $env:npm_config_cache = "$sgfTools\npm-cache" $env:npm_config_userconfig = "$sgfTools\npmrc" Remove-Item Env:COMPOSER_AUTH -ErrorAction SilentlyContinue Set-Location -LiteralPath $sgfProject New-Item -ItemType Directory -Path $sgfRun -Force | Out-Null function Assert-FreePort([int]$Port) { if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) { throw "Port $Port is occupied. No process was stopped." } } function Start-SgfDatabase { $listeners = @(Get-NetTCPConnection -LocalPort 55432 -State Listen -ErrorAction SilentlyContinue) if ($listeners.Count) { $pidFile = Join-Path $sgfCluster 'postmaster.pid' if (!(Test-Path -LiteralPath $pidFile)) { throw 'Port 55432 belongs to another process.' } $clusterPid = [int](Get-Content -LiteralPath $pidFile -TotalCount 1) $process = Get-CimInstance Win32_Process -Filter "ProcessId = $clusterPid" $normalizedCommand = if ($process) { $process.CommandLine.Replace('/', '\') } else { '' } if (($listeners.OwningProcess -notcontains $clusterPid) -or !$process -or $process.ExecutablePath -ne "$sgfPg\postgres.exe" -or $normalizedCommand -notlike "*$sgfCluster*") { throw 'Port 55432 is not owned by the SGF cluster. No process was stopped.' } return } Assert-FreePort 55432 & "$sgfPg\pg_ctl.exe" -D $sgfCluster -l (Join-Path $sgfData 'postgres.log') -w start if ($LASTEXITCODE) { throw 'SGF database start failed.' } } function Start-SgfProcess([string]$Name, [string]$Executable, [string[]]$Arguments, [int]$Port) { Assert-FreePort $Port $process = Start-Process -FilePath $Executable -ArgumentList $Arguments -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\$Name.stdout.log" -RedirectStandardError "$sgfRun\$Name.stderr.log" $record = @{pid=$process.Id; executable=$Executable; project=$sgfProject; port=$Port; started=$process.StartTime.ToUniversalTime().ToString('o')} $record | ConvertTo-Json | Set-Content -LiteralPath "$sgfRun\$Name.json" -Encoding UTF8 $ready = $false for ($attempt = 0; $attempt -lt 40; $attempt++) { $process.Refresh() if ($process.HasExited) { throw "$Name exited. See SGF run logs." } $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) if ($listeners.OwningProcess -contains $process.Id) { $ready = $true; break } Start-Sleep -Milliseconds 250 } if (!$ready) { throw "$Name did not become ready. No other process was stopped." } Write-Output "$Name ready at http://127.0.0.1:$Port" } switch ($Action) { 'start' { Assert-FreePort 8000 Start-SgfDatabase Start-SgfProcess 'web' $sgfPhp @('-S', '127.0.0.1:8000', '-t', 'public', 'scripts/router.php') 8000 } 'frontend' { Start-SgfProcess 'vite' $sgfNode @('node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '5173', '--strictPort') 5173 } 'status' { & "$sgfPg\pg_ctl.exe" -D $sgfCluster status Get-NetTCPConnection -LocalPort 55432,8000,5173 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress,LocalPort,OwningProcess } 'stop' { foreach ($name in @('vite', 'web')) { $recordFile = "$sgfRun\$name.json" if (Test-Path -LiteralPath $recordFile) { $record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json $process = Get-Process -Id $record.pid -ErrorAction SilentlyContinue if ($process) { if ($process.Path -ne $record.executable -or $record.project -ne $sgfProject -or $process.StartTime.ToUniversalTime().ToString('o') -ne $record.started) { throw "Process identity mismatch for $name; refusing to stop it." } Stop-Process -Id $process.Id } Remove-Item -LiteralPath $recordFile if ($name -eq 'vite') { $hotFile = Join-Path $sgfProject 'public\hot' if ((Test-Path -LiteralPath $hotFile) -and (Get-Content -LiteralPath $hotFile -Raw).Trim() -eq 'http://127.0.0.1:5173') { Remove-Item -LiteralPath $hotFile } } } } & "$sgfPg\pg_ctl.exe" -D $sgfCluster -m fast -w stop if ($LASTEXITCODE -ne 0 -and (Test-Path -LiteralPath "$sgfCluster\postmaster.pid")) { throw 'SGF cluster stop failed.' } } 'php' { & $sgfPhp @ToolArguments; if ($LASTEXITCODE) { throw 'PHP command failed.' } } 'composer' { & $sgfPhp $sgfComposer @ToolArguments; if ($LASTEXITCODE) { throw 'Composer command failed.' } } 'npm' { & $sgfNode $sgfNpmCli @ToolArguments; if ($LASTEXITCODE) { throw 'npm command failed.' } } 'test' { $demoManifest = Join-Path $sgfData 'secrets\browser-fixture.json' if (Test-Path -LiteralPath $demoManifest) { $demoFixture = Get-Content -LiteralPath $demoManifest -Raw | ConvertFrom-Json if ($demoFixture.product_demo) { throw 'Integrated team demo preserved in sgf_tempo_test. Do not reset it for regression tests; use a separate test environment.' } } if (Test-Path -LiteralPath "$sgfRun\browser-proof.json") { throw 'Stop the browser proof before tests reset sgf_tempo_test.' } Start-SgfDatabase & $sgfPhp 'vendor/phpunit/phpunit/phpunit' @ToolArguments if ($LASTEXITCODE) { throw 'Tests failed.' } } 'migrate' { Start-SgfDatabase $credentials = Get-Content -LiteralPath "$sgfData\secrets\database.json" -Raw | ConvertFrom-Json $env:APP_ENV = 'local' $env:DB_CONNECTION = 'pgsql' $env:DB_HOST = '127.0.0.1' $env:DB_PORT = '55432' $env:DB_DATABASE = 'sgf_tempo_dev' $env:DB_USERNAME = 'sgf_dev_owner' $env:DB_PASSWORD = $credentials.dev_owner $env:DB_URL = '' try { & $sgfPhp artisan migrate --no-interaction if ($LASTEXITCODE) { throw 'Development migration failed.' } } finally { foreach ($name in @('APP_ENV','DB_CONNECTION','DB_HOST','DB_PORT','DB_DATABASE','DB_USERNAME','DB_PASSWORD','DB_URL')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } } } }