manage-project.ps1 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. [CmdletBinding()]
  2. param(
  3. [Parameter(Position = 0)]
  4. [ValidateSet('menu', 'start', 'restart', 'stop', 'status', 'publish', 'publish-data')]
  5. [string]$Action = 'menu',
  6. [ValidateSet('split', 'wails')]
  7. [string]$Mode = 'split'
  8. )
  9. $ErrorActionPreference = 'Stop'
  10. $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
  11. $RunDir = Join-Path $Root '.run'
  12. $StatePath = Join-Path $RunDir 'project-processes.json'
  13. $BackendExe = Join-Path $RunDir 'smart-parking-backend.exe'
  14. $WailsGoCache = Join-Path $Root '.gocache-wails'
  15. $BackendBuildLog = Join-Path $RunDir 'backend-build.log'
  16. $BackendStdout = Join-Path $RunDir 'backend.stdout.log'
  17. $BackendStderr = Join-Path $RunDir 'backend.stderr.log'
  18. $FrontendStdout = Join-Path $RunDir 'frontend.stdout.log'
  19. $FrontendStderr = Join-Path $RunDir 'frontend.stderr.log'
  20. $WailsStdout = Join-Path $RunDir 'wails.stdout.log'
  21. $WailsStderr = Join-Path $RunDir 'wails.stderr.log'
  22. $PublishScript = Join-Path $PSScriptRoot 'publish-release.ps1'
  23. function Ensure-RunDirectory {
  24. if (-not (Test-Path -LiteralPath $RunDir)) {
  25. New-Item -ItemType Directory -Path $RunDir | Out-Null
  26. }
  27. }
  28. function Initialize-LogFile([string]$Path) {
  29. try {
  30. Set-Content -LiteralPath $Path -Value '' -Encoding UTF8 -ErrorAction Stop
  31. return $Path
  32. } catch {
  33. # A previous npm.cmd wrapper can retain its redirected file handle even
  34. # after Vite exits. Keep the old log intact and use a new file for this
  35. # launch instead of making the project impossible to restart.
  36. $directory = Split-Path -Parent $Path
  37. $name = [System.IO.Path]::GetFileNameWithoutExtension($Path)
  38. $extension = [System.IO.Path]::GetExtension($Path)
  39. $fallback = Join-Path $directory ("{0}.{1}{2}" -f $name, (Get-Date -Format 'yyyyMMdd-HHmmss'), $extension)
  40. Set-Content -LiteralPath $fallback -Value '' -Encoding UTF8
  41. Write-Warning "Log file is locked: $Path. This launch will write to: $fallback"
  42. return $fallback
  43. }
  44. }
  45. function Read-State {
  46. if (-not (Test-Path -LiteralPath $StatePath)) { return $null }
  47. try {
  48. return Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
  49. } catch {
  50. Write-Warning "Ignoring invalid state file: $StatePath"
  51. return $null
  52. }
  53. }
  54. function Write-State([string]$RunMode, [array]$Processes) {
  55. Ensure-RunDirectory
  56. [ordered]@{
  57. root = $Root
  58. mode = $RunMode
  59. started_at = (Get-Date).ToString('o')
  60. processes = $Processes
  61. } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $StatePath -Encoding UTF8
  62. }
  63. function Remove-State {
  64. if (Test-Path -LiteralPath $StatePath) {
  65. Remove-Item -LiteralPath $StatePath -Force
  66. }
  67. }
  68. function Stop-ProcessTree([int]$ProcessId) {
  69. if ($ProcessId -le 0) { return $true }
  70. $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
  71. if ($null -eq $process) { return $true }
  72. Write-Host "Stopping $($process.ProcessName) (PID $ProcessId)"
  73. & taskkill.exe /PID $ProcessId /T /F 2>$null | Out-Null
  74. Start-Sleep -Milliseconds 300
  75. return $null -eq (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)
  76. }
  77. function Stop-PortProcess([int]$Port) {
  78. # npm.cmd exits after handing the server to node, so the state-file PID may
  79. # no longer own the listening socket. Clean up only this project's ports.
  80. $connections = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
  81. foreach ($connection in $connections) {
  82. $owner = [int]$connection.OwningProcess
  83. if ($owner -gt 0) {
  84. if (-not (Stop-ProcessTree $owner)) {
  85. throw "Failed to stop process PID $owner listening on port $Port."
  86. }
  87. }
  88. }
  89. }
  90. function Stop-StaleBackendProcess {
  91. # project-processes.json may be missing after an interrupted PowerShell
  92. # session. In that case, still reclaim 8888 only when its owner is this
  93. # project's backend executable. Do not terminate an unrelated service.
  94. $connections = @(Get-NetTCPConnection -LocalPort 8888 -State Listen -ErrorAction SilentlyContinue)
  95. foreach ($connection in $connections) {
  96. $process = Get-Process -Id ([int]$connection.OwningProcess) -ErrorAction SilentlyContinue
  97. if ($null -eq $process) { continue }
  98. $isProjectBackend = $false
  99. if ($process.ProcessName -eq 'smart-parking-backend' -and $process.Path) {
  100. $isProjectBackend = [string]::Equals(
  101. [System.IO.Path]::GetFullPath($process.Path),
  102. [System.IO.Path]::GetFullPath($BackendExe),
  103. [System.StringComparison]::OrdinalIgnoreCase
  104. )
  105. }
  106. if (-not $isProjectBackend) {
  107. 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."
  108. }
  109. if (-not (Stop-ProcessTree $process.Id)) {
  110. throw "Failed to stop stale project backend PID $($process.Id) on port 8888."
  111. }
  112. }
  113. }
  114. function Stop-ManagedProcesses {
  115. $state = Read-State
  116. if ($null -eq $state) {
  117. Write-Host 'No managed project state found; checking for a stale project backend on port 8888.'
  118. Stop-StaleBackendProcess
  119. return
  120. }
  121. foreach ($item in @($state.processes)) {
  122. if (-not (Stop-ProcessTree ([int]$item.pid))) {
  123. # npm.cmd can remain as an inert wrapper after its node child has
  124. # stopped. The listening port check below is the authoritative
  125. # result, so do not prevent a restart merely because that wrapper
  126. # cannot be reaped immediately.
  127. Write-Warning "Process $($item.name) (PID $($item.pid)) is still present; verifying port $($item.port)."
  128. }
  129. }
  130. foreach ($item in @($state.processes)) {
  131. Stop-PortProcess ([int]$item.port)
  132. }
  133. # A previous launch can leave an untracked backend after the state file is
  134. # removed. Clean it after the tracked processes as well.
  135. Stop-StaleBackendProcess
  136. Remove-State
  137. Write-Host 'Project processes stopped.'
  138. }
  139. function Assert-Command([string]$Name) {
  140. if ($null -eq (Get-Command $Name -ErrorAction SilentlyContinue)) {
  141. throw "Required command not found: $Name"
  142. }
  143. }
  144. function Wait-Http([string]$Url, [int]$TimeoutSeconds = 30) {
  145. for ($i = 0; $i -lt $TimeoutSeconds; $i++) {
  146. try {
  147. $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
  148. if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { return $true }
  149. } catch { }
  150. Start-Sleep -Seconds 1
  151. }
  152. return $false
  153. }
  154. function Start-SplitMode {
  155. Assert-Command 'go'
  156. Assert-Command 'npm'
  157. Ensure-RunDirectory
  158. Stop-StaleBackendProcess
  159. # RedirectStandardOutput/RedirectStandardError may append on some
  160. # PowerShell versions. Start every launch with fresh logs so an old panic
  161. # is never reported as the current startup result.
  162. $BackendStdout = Initialize-LogFile $BackendStdout
  163. $BackendStderr = Initialize-LogFile $BackendStderr
  164. $FrontendStdout = Initialize-LogFile $FrontendStdout
  165. $FrontendStderr = Initialize-LogFile $FrontendStderr
  166. # Keep Go build artifacts in the project workspace. The default user cache
  167. # can be inaccessible on locked-down Windows installations.
  168. New-Item -ItemType Directory -Path $WailsGoCache -Force | Out-Null
  169. $env:GOCACHE = $WailsGoCache
  170. Write-Host 'Building backend...'
  171. Push-Location $Root
  172. try {
  173. & go build -o $BackendExe .\cmd_backend_only\ 2>&1 | Tee-Object -FilePath $BackendBuildLog
  174. if ($LASTEXITCODE -ne 0) { throw 'Backend build failed.' }
  175. } finally {
  176. Pop-Location
  177. }
  178. $backend = Start-Process -FilePath $BackendExe -WorkingDirectory $Root `
  179. -RedirectStandardOutput $BackendStdout -RedirectStandardError $BackendStderr -PassThru
  180. if (-not (Wait-Http 'http://127.0.0.1:8888/health')) {
  181. Stop-ProcessTree $backend.Id | Out-Null
  182. throw "Backend did not become ready. See $BackendStdout and $BackendStderr"
  183. }
  184. $frontend = Start-Process -FilePath 'npm.cmd' -ArgumentList @('run', 'dev') `
  185. -WorkingDirectory (Join-Path $Root 'frontend') `
  186. -RedirectStandardOutput $FrontendStdout -RedirectStandardError $FrontendStderr -PassThru
  187. Write-State 'split' @(
  188. [ordered]@{ name = 'backend'; pid = $backend.Id; port = 8888 },
  189. [ordered]@{ name = 'frontend'; pid = $frontend.Id; port = 5173 }
  190. )
  191. Write-Host 'Backend ready: http://127.0.0.1:8888'
  192. Write-Host 'Frontend: http://localhost:5173'
  193. }
  194. function Start-WailsMode {
  195. Assert-Command 'wails'
  196. Ensure-RunDirectory
  197. New-Item -ItemType Directory -Path $WailsGoCache -Force | Out-Null
  198. # Keep Wails/Go build artifacts in the project workspace. The default
  199. # user cache can be inaccessible on locked-down Windows installations.
  200. $env:GOCACHE = $WailsGoCache
  201. $wails = Start-Process -FilePath 'wails' -ArgumentList @('dev') `
  202. -WorkingDirectory $Root -RedirectStandardOutput $WailsStdout `
  203. -RedirectStandardError $WailsStderr -PassThru
  204. Write-State 'wails' @([ordered]@{ name = 'wails'; pid = $wails.Id; port = 5173 })
  205. Write-Host "Wails dev started (PID $($wails.Id)). See $WailsStdout and $WailsStderr"
  206. }
  207. function Show-Status {
  208. $state = Read-State
  209. if ($null -eq $state) {
  210. Write-Host 'Project is not managed by this script.'
  211. return
  212. }
  213. Write-Host "Mode: $($state.mode)"
  214. Write-Host "Started: $($state.started_at)"
  215. foreach ($item in @($state.processes)) {
  216. $process = Get-Process -Id ([int]$item.pid) -ErrorAction SilentlyContinue
  217. $status = if ($null -eq $process) { 'stopped' } else { 'running' }
  218. Write-Host ("{0}: PID {1}, port {2}, {3}" -f $item.name, $item.pid, $item.port, $status)
  219. }
  220. }
  221. function Publish-Release([bool]$IncludeExistingData = $false) {
  222. if (-not (Test-Path -LiteralPath $PublishScript -PathType Leaf)) {
  223. throw "Publish script not found: $PublishScript"
  224. }
  225. Write-Host 'Building deployment package...' -ForegroundColor Cyan
  226. if ($IncludeExistingData) {
  227. & $PublishScript -IncludeDatabase -IncludeUploads
  228. } else {
  229. & $PublishScript
  230. }
  231. }
  232. function Show-Menu {
  233. while ($true) {
  234. Clear-Host
  235. Write-Host '========================================' -ForegroundColor Cyan
  236. Write-Host ' Smart Parking Project Manager' -ForegroundColor Cyan
  237. Write-Host '========================================'
  238. Write-Host '1. Start project'
  239. Write-Host '2. Stop project'
  240. Write-Host '3. Restart project'
  241. Write-Host '4. Show status'
  242. Write-Host '5. Build deployment package'
  243. Write-Host '6. Build deployment package with current database and images'
  244. Write-Host '0. Exit'
  245. Write-Host ''
  246. $choice = Read-Host 'Select action'
  247. if ($choice -eq '1') {
  248. if ($null -ne (Read-State)) {
  249. Write-Host 'Project is already running; use stop or restart.' -ForegroundColor Yellow
  250. } elseif ($Mode -eq 'wails') {
  251. Start-WailsMode
  252. } else {
  253. Start-SplitMode
  254. }
  255. } elseif ($choice -eq '2') {
  256. Stop-ManagedProcesses
  257. } elseif ($choice -eq '3') {
  258. Stop-ManagedProcesses
  259. if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode }
  260. } elseif ($choice -eq '4') {
  261. Show-Status
  262. } elseif ($choice -eq '5') {
  263. Publish-Release
  264. } elseif ($choice -eq '6') {
  265. Publish-Release $true
  266. } elseif ($choice -eq '0') {
  267. return
  268. } else {
  269. Write-Host 'Invalid option.' -ForegroundColor Red
  270. }
  271. if ($choice -ne '0') {
  272. Write-Host ''
  273. Read-Host 'Press Enter to return to menu'
  274. }
  275. }
  276. }
  277. switch ($Action) {
  278. 'menu' {
  279. Show-Menu
  280. }
  281. 'stop' {
  282. Stop-ManagedProcesses
  283. }
  284. 'status' {
  285. Show-Status
  286. }
  287. 'restart' {
  288. Stop-ManagedProcesses
  289. if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode }
  290. }
  291. 'start' {
  292. if ($null -ne (Read-State)) {
  293. Write-Host 'A managed instance already exists; use restart or stop first.'
  294. exit 1
  295. }
  296. if ($Mode -eq 'wails') { Start-WailsMode } else { Start-SplitMode }
  297. }
  298. 'publish' {
  299. Publish-Release
  300. }
  301. 'publish-data' {
  302. Publish-Release $true
  303. }
  304. }