[CmdletBinding()] param( [Parameter(Position = 0)] [ValidateSet('menu', 'start', 'restart', 'stop', 'status', 'publish', 'publish-data')] [string]$Action = 'menu', [ValidateSet('split', 'wails')] [string]$Mode = 'split' ) $ErrorActionPreference = 'Stop' $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $RunDir = Join-Path $Root '.run' $StatePath = Join-Path $RunDir 'project-processes.json' $BackendExe = Join-Path $RunDir 'smart-parking-backend.exe' $WailsGoCache = Join-Path $Root '.gocache-wails' $BackendBuildLog = Join-Path $RunDir 'backend-build.log' $BackendStdout = Join-Path $RunDir 'backend.stdout.log' $BackendStderr = Join-Path $RunDir 'backend.stderr.log' $FrontendStdout = Join-Path $RunDir 'frontend.stdout.log' $FrontendStderr = Join-Path $RunDir 'frontend.stderr.log' $WailsStdout = Join-Path $RunDir 'wails.stdout.log' $WailsStderr = Join-Path $RunDir 'wails.stderr.log' $PublishScript = Join-Path $PSScriptRoot 'publish-release.ps1' function Ensure-RunDirectory { if (-not (Test-Path -LiteralPath $RunDir)) { New-Item -ItemType Directory -Path $RunDir | Out-Null } } function Initialize-LogFile([string]$Path) { try { Set-Content -LiteralPath $Path -Value '' -Encoding UTF8 -ErrorAction Stop return $Path } catch { # A previous npm.cmd wrapper can retain its redirected file handle even # after Vite exits. Keep the old log intact and use a new file for this # launch instead of making the project impossible to restart. $directory = Split-Path -Parent $Path $name = [System.IO.Path]::GetFileNameWithoutExtension($Path) $extension = [System.IO.Path]::GetExtension($Path) $fallback = Join-Path $directory ("{0}.{1}{2}" -f $name, (Get-Date -Format 'yyyyMMdd-HHmmss'), $extension) Set-Content -LiteralPath $fallback -Value '' -Encoding UTF8 Write-Warning "Log file is locked: $Path. This launch will write to: $fallback" return $fallback } } function Read-State { if (-not (Test-Path -LiteralPath $StatePath)) { return $null } try { return Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json } catch { Write-Warning "Ignoring invalid state file: $StatePath" return $null } } function Write-State([string]$RunMode, [array]$Processes) { Ensure-RunDirectory [ordered]@{ root = $Root mode = $RunMode started_at = (Get-Date).ToString('o') processes = $Processes } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $StatePath -Encoding UTF8 } function Remove-State { if (Test-Path -LiteralPath $StatePath) { Remove-Item -LiteralPath $StatePath -Force } } function Stop-ProcessTree([int]$ProcessId) { if ($ProcessId -le 0) { return $true } $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue if ($null -eq $process) { return $true } Write-Host "Stopping $($process.ProcessName) (PID $ProcessId)" & taskkill.exe /PID $ProcessId /T /F 2>$null | Out-Null Start-Sleep -Milliseconds 300 return $null -eq (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) } function Stop-PortProcess([int]$Port) { # npm.cmd exits after handing the server to node, so the state-file PID may # no longer own the listening socket. Clean up only this project's ports. $connections = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) foreach ($connection in $connections) { $owner = [int]$connection.OwningProcess if ($owner -gt 0) { if (-not (Stop-ProcessTree $owner)) { throw "Failed to stop process PID $owner listening on port $Port." } } } } function Stop-StaleBackendProcess { # project-processes.json may be missing after an interrupted PowerShell # session. In that case, still reclaim 8888 only when its owner is this # project's backend executable. Do not terminate an unrelated service. $connections = @(Get-NetTCPConnection -LocalPort 8888 -State Listen -ErrorAction SilentlyContinue) foreach ($connection in $connections) { $process = Get-Process -Id ([int]$connection.OwningProcess) -ErrorAction SilentlyContinue if ($null -eq $process) { continue } $isProjectBackend = $false if ($process.ProcessName -eq 'smart-parking-backend' -and $process.Path) { $isProjectBackend = [string]::Equals( [System.IO.Path]::GetFullPath($process.Path), [System.IO.Path]::GetFullPath($BackendExe), [System.StringComparison]::OrdinalIgnoreCase ) } if (-not $isProjectBackend) { throw "Port 8888 is occupied by $($process.ProcessName) (PID $($process.Id), path: $($process.Path)). It is not this project's managed backend, so it was not stopped." } if (-not (Stop-ProcessTree $process.Id)) { throw "Failed to stop stale project backend PID $($process.Id) on port 8888." } } } function Stop-ManagedProcesses { $state = Read-State if ($null -eq $state) { Write-Host 'No managed project state found; checking for a stale project backend on port 8888.' Stop-StaleBackendProcess return } foreach ($item in @($state.processes)) { if (-not (Stop-ProcessTree ([int]$item.pid))) { # npm.cmd can remain as an inert wrapper after its node child has # stopped. The listening port check below is the authoritative # result, so do not prevent a restart merely because that wrapper # cannot be reaped immediately. Write-Warning "Process $($item.name) (PID $($item.pid)) is still present; verifying port $($item.port)." } } foreach ($item in @($state.processes)) { Stop-PortProcess ([int]$item.port) } # A previous launch can leave an untracked backend after the state file is # removed. Clean it after the tracked processes as well. Stop-StaleBackendProcess Remove-State Write-Host 'Project processes stopped.' } function Assert-Command([string]$Name) { if ($null -eq (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command not found: $Name" } } function Wait-Http([string]$Url, [int]$TimeoutSeconds = 30) { for ($i = 0; $i -lt $TimeoutSeconds; $i++) { try { $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { return $true } } catch { } Start-Sleep -Seconds 1 } return $false } function Start-SplitMode { Assert-Command 'go' Assert-Command 'npm' Ensure-RunDirectory Stop-StaleBackendProcess # RedirectStandardOutput/RedirectStandardError may append on some # PowerShell versions. Start every launch with fresh logs so an old panic # is never reported as the current startup result. $BackendStdout = Initialize-LogFile $BackendStdout $BackendStderr = Initialize-LogFile $BackendStderr $FrontendStdout = Initialize-LogFile $FrontendStdout $FrontendStderr = Initialize-LogFile $FrontendStderr # Keep Go build artifacts in the project workspace. The default user cache # can be inaccessible on locked-down Windows installations. New-Item -ItemType Directory -Path $WailsGoCache -Force | Out-Null $env:GOCACHE = $WailsGoCache Write-Host 'Building backend...' Push-Location $Root try { & go build -o $BackendExe .\cmd_backend_only\ 2>&1 | Tee-Object -FilePath $BackendBuildLog if ($LASTEXITCODE -ne 0) { throw 'Backend build failed.' } } finally { Pop-Location } $backend = Start-Process -FilePath $BackendExe -WorkingDirectory $Root ` -RedirectStandardOutput $BackendStdout -RedirectStandardError $BackendStderr -PassThru if (-not (Wait-Http 'http://127.0.0.1:8888/health')) { Stop-ProcessTree $backend.Id | Out-Null throw "Backend did not become ready. See $BackendStdout and $BackendStderr" } $frontend = Start-Process -FilePath 'npm.cmd' -ArgumentList @('run', 'dev') ` -WorkingDirectory (Join-Path $Root 'frontend') ` -RedirectStandardOutput $FrontendStdout -RedirectStandardError $FrontendStderr -PassThru Write-State 'split' @( [ordered]@{ name = 'backend'; pid = $backend.Id; port = 8888 }, [ordered]@{ name = 'frontend'; pid = $frontend.Id; port = 5173 } ) Write-Host 'Backend ready: http://127.0.0.1:8888' Write-Host 'Frontend: http://localhost:5173' } function Start-WailsMode { Assert-Command 'wails' Ensure-RunDirectory New-Item -ItemType Directory -Path $WailsGoCache -Force | Out-Null # Keep Wails/Go build artifacts in the project workspace. The default # user cache can be inaccessible on locked-down Windows installations. $env:GOCACHE = $WailsGoCache $wails = Start-Process -FilePath 'wails' -ArgumentList @('dev') ` -WorkingDirectory $Root -RedirectStandardOutput $WailsStdout ` -RedirectStandardError $WailsStderr -PassThru Write-State 'wails' @([ordered]@{ name = 'wails'; pid = $wails.Id; port = 5173 }) Write-Host "Wails dev started (PID $($wails.Id)). See $WailsStdout and $WailsStderr" } function Show-Status { $state = Read-State if ($null -eq $state) { Write-Host 'Project is not managed by this script.' return } Write-Host "Mode: $($state.mode)" Write-Host "Started: $($state.started_at)" foreach ($item in @($state.processes)) { $process = Get-Process -Id ([int]$item.pid) -ErrorAction SilentlyContinue $status = if ($null -eq $process) { 'stopped' } else { 'running' } Write-Host ("{0}: PID {1}, port {2}, {3}" -f $item.name, $item.pid, $item.port, $status) } } function Publish-Release([bool]$IncludeExistingData = $false) { if (-not (Test-Path -LiteralPath $PublishScript -PathType Leaf)) { throw "Publish script not found: $PublishScript" } Write-Host 'Building deployment package...' -ForegroundColor Cyan if ($IncludeExistingData) { & $PublishScript -IncludeDatabase -IncludeUploads } else { & $PublishScript } } function Show-Menu { while ($true) { Clear-Host Write-Host '========================================' -ForegroundColor Cyan Write-Host ' Smart Parking Project Manager' -ForegroundColor Cyan Write-Host '========================================' Write-Host '1. Start project' Write-Host '2. Stop project' Write-Host '3. Restart project' Write-Host '4. Show status' Write-Host '5. Build deployment package' Write-Host '6. Build deployment package with current database and images' Write-Host '0. Exit' Write-Host '' $choice = Read-Host 'Select action' if ($choice -eq '1') { if ($null -ne (Read-State)) { Write-Host 'Project is already running; use stop or restart.' -ForegroundColor Yellow } elseif ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode } } elseif ($choice -eq '2') { Stop-ManagedProcesses } elseif ($choice -eq '3') { Stop-ManagedProcesses if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode } } elseif ($choice -eq '4') { Show-Status } elseif ($choice -eq '5') { Publish-Release } elseif ($choice -eq '6') { Publish-Release $true } elseif ($choice -eq '0') { return } else { Write-Host 'Invalid option.' -ForegroundColor Red } if ($choice -ne '0') { Write-Host '' Read-Host 'Press Enter to return to menu' } } } switch ($Action) { 'menu' { Show-Menu } 'stop' { Stop-ManagedProcesses } 'status' { Show-Status } 'restart' { Stop-ManagedProcesses if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode } } 'start' { if ($null -ne (Read-State)) { Write-Host 'A managed instance already exists; use restart or stop first.' exit 1 } if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode } } 'publish' { Publish-Release } 'publish-data' { Publish-Release $true } }