start-local.ps1 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. param(
  2. [switch]$Restart,
  3. [switch]$ForceKillPorts,
  4. [switch]$InstallDependencies,
  5. [switch]$InstallBrowsers,
  6. [switch]$InitDatabase,
  7. [switch]$SeedDatabase
  8. )
  9. $ErrorActionPreference = "Stop"
  10. Set-StrictMode -Version Latest
  11. $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
  12. $RuntimeDir = Join-Path $RepoRoot ".runtime"
  13. $Services = @(
  14. @{
  15. Name = "api"
  16. Port = 3001
  17. Command = "npm.cmd run dev:api"
  18. ReadyUrl = "http://localhost:3001/health"
  19. ReadyText = '"ok":true'
  20. },
  21. @{
  22. Name = "web"
  23. Port = 3000
  24. Command = "npm.cmd run dev:web"
  25. ReadyUrl = "http://localhost:3000"
  26. ReadyText = ""
  27. }
  28. )
  29. function Write-Step([string]$Message) {
  30. Write-Host "==> $Message" -ForegroundColor Cyan
  31. }
  32. function Write-WarnLine([string]$Message) {
  33. Write-Host "WARN: $Message" -ForegroundColor Yellow
  34. }
  35. function Get-PidFilePath([string]$Name) {
  36. Join-Path $RuntimeDir "$Name.pid"
  37. }
  38. function Get-LogPath([string]$Name) {
  39. Join-Path $RuntimeDir "$Name.log"
  40. }
  41. function Get-ErrLogPath([string]$Name) {
  42. Join-Path $RuntimeDir "$Name.err.log"
  43. }
  44. function Get-ManagedPid([string]$Name) {
  45. $pidFile = Get-PidFilePath $Name
  46. if (-not (Test-Path $pidFile)) {
  47. return $null
  48. }
  49. $raw = Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1
  50. if (-not $raw) {
  51. return $null
  52. }
  53. $parsed = 0
  54. if ([int]::TryParse($raw, [ref]$parsed)) {
  55. return $parsed
  56. }
  57. return $null
  58. }
  59. function Test-ProcessRunning([int]$ProcessId) {
  60. try {
  61. $null = Get-Process -Id $ProcessId -ErrorAction Stop
  62. return $true
  63. } catch {
  64. return $false
  65. }
  66. }
  67. function Get-ProcessCommandLine([int]$ProcessId) {
  68. try {
  69. $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId"
  70. if ($null -eq $process -or $null -eq $process.CommandLine) {
  71. return ""
  72. }
  73. return [string]$process.CommandLine
  74. } catch {
  75. return ""
  76. }
  77. }
  78. function Test-SentAIProcess([int]$ProcessId) {
  79. $commandLine = Get-ProcessCommandLine $ProcessId
  80. if (-not $commandLine) {
  81. return $false
  82. }
  83. return $commandLine -like "*SentAI*" -or
  84. $commandLine -like "*@sentai*" -or
  85. $commandLine -like "*npm.cmd run dev:api*" -or
  86. $commandLine -like "*npm.cmd run dev:web*"
  87. }
  88. function Stop-ProcessTree([int]$ProcessId) {
  89. & taskkill /PID $ProcessId /T /F | Out-Null
  90. }
  91. function Get-ListenerOwners([int]$Port) {
  92. try {
  93. return @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
  94. Select-Object -ExpandProperty OwningProcess -Unique)
  95. } catch {
  96. return @()
  97. }
  98. }
  99. function Test-UrlReady([string]$Url, [string]$ExpectedText) {
  100. try {
  101. $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3
  102. if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) {
  103. return $false
  104. }
  105. if ([string]::IsNullOrWhiteSpace($ExpectedText)) {
  106. return $true
  107. }
  108. $content = [string]$response.Content
  109. return $content.Contains($ExpectedText)
  110. } catch {
  111. return $false
  112. }
  113. }
  114. function Wait-ForReady([hashtable]$Service, [int]$TimeoutSeconds = 90) {
  115. $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
  116. while ((Get-Date) -lt $deadline) {
  117. if (Test-UrlReady -Url $Service.ReadyUrl -ExpectedText $Service.ReadyText) {
  118. return $true
  119. }
  120. Start-Sleep -Seconds 2
  121. }
  122. return $false
  123. }
  124. function Ensure-PortAvailable([hashtable]$Service) {
  125. [array]$owners = @(Get-ListenerOwners $Service.Port)
  126. if ($owners.Count -eq 0) {
  127. return
  128. }
  129. foreach ($owner in $owners) {
  130. if ($ForceKillPorts -or (Test-SentAIProcess $owner)) {
  131. Write-WarnLine "Stopping process $owner on port $($Service.Port)."
  132. Stop-ProcessTree $owner
  133. continue
  134. }
  135. $commandLine = Get-ProcessCommandLine $owner
  136. throw "Port $($Service.Port) is already used by process $owner. Use -ForceKillPorts if you want the script to clear it. CommandLine: $commandLine"
  137. }
  138. Start-Sleep -Seconds 2
  139. [array]$remaining = @(Get-ListenerOwners $Service.Port)
  140. if ($remaining.Count -gt 0) {
  141. throw "Port $($Service.Port) is still occupied after cleanup."
  142. }
  143. }
  144. function Ensure-ManagedProcessState([hashtable]$Service) {
  145. $managedPid = Get-ManagedPid $Service.Name
  146. if (-not $managedPid) {
  147. return
  148. }
  149. if (-not (Test-ProcessRunning $managedPid)) {
  150. Remove-Item (Get-PidFilePath $Service.Name) -ErrorAction SilentlyContinue
  151. return
  152. }
  153. if ($Restart) {
  154. Write-WarnLine "Restart requested. Stopping existing managed $($Service.Name) process $managedPid."
  155. Stop-ProcessTree $managedPid
  156. Remove-Item (Get-PidFilePath $Service.Name) -ErrorAction SilentlyContinue
  157. Start-Sleep -Seconds 2
  158. return
  159. }
  160. if (Test-UrlReady -Url $Service.ReadyUrl -ExpectedText $Service.ReadyText) {
  161. Write-Step "$($Service.Name) is already running on port $($Service.Port)."
  162. $Service.SkipStart = $true
  163. return
  164. }
  165. Write-WarnLine "Managed $($Service.Name) process $managedPid exists but is not healthy. Restarting it."
  166. Stop-ProcessTree $managedPid
  167. Remove-Item (Get-PidFilePath $Service.Name) -ErrorAction SilentlyContinue
  168. Start-Sleep -Seconds 2
  169. }
  170. function Start-ServiceProcess([hashtable]$Service) {
  171. if ($Service.ContainsKey("SkipStart") -and $Service.SkipStart) {
  172. return
  173. }
  174. $stdout = Get-LogPath $Service.Name
  175. $stderr = Get-ErrLogPath $Service.Name
  176. Remove-Item $stdout, $stderr -ErrorAction SilentlyContinue
  177. Write-Step "Starting $($Service.Name) with '$($Service.Command)'."
  178. $process = Start-Process `
  179. -FilePath "cmd.exe" `
  180. -ArgumentList "/c", $Service.Command `
  181. -WorkingDirectory $RepoRoot `
  182. -RedirectStandardOutput $stdout `
  183. -RedirectStandardError $stderr `
  184. -PassThru
  185. Set-Content -Path (Get-PidFilePath $Service.Name) -Value $process.Id
  186. if (-not (Wait-ForReady -Service $Service)) {
  187. throw "$($Service.Name) did not become ready in time. Check $stdout and $stderr"
  188. }
  189. Write-Step "$($Service.Name) is ready at $($Service.ReadyUrl)."
  190. }
  191. New-Item -ItemType Directory -Path $RuntimeDir -Force | Out-Null
  192. Set-Location $RepoRoot
  193. if ($InstallDependencies) {
  194. Write-Step "Installing npm dependencies."
  195. & npm.cmd install
  196. }
  197. if ($InitDatabase) {
  198. Write-Step "Initializing database schema."
  199. & npm.cmd run db:init
  200. }
  201. if ($SeedDatabase) {
  202. Write-Step "Seeding database."
  203. & npm.cmd run db:seed
  204. }
  205. if ($InstallBrowsers) {
  206. Write-Step "Installing Playwright browsers."
  207. & npm.cmd run install:browsers
  208. }
  209. foreach ($service in $Services) {
  210. Ensure-ManagedProcessState $service
  211. Ensure-PortAvailable $service
  212. Start-ServiceProcess $service
  213. }
  214. Write-Host ""
  215. Write-Host "SentAI local environment is running." -ForegroundColor Green
  216. Write-Host " Web: http://localhost:3000"
  217. Write-Host " API: http://localhost:3001/health"