# dev script helpers (ASCII only for Windows PowerShell 5.1) . (Join-Path $PSScriptRoot "config.ps1") function Write-DevLog { param( [string]$Message, [ValidateSet("Info", "Ok", "Warn", "Err")] [string]$Level = "Info" ) $prefix = switch ($Level) { "Ok" { "[OK] " } "Warn" { "[WARN] " } "Err" { "[ERR] " } default { "[..] " } } Write-Host "$prefix$Message" } function Invoke-DevNativeCommand { param( [Parameter(Mandatory = $true)] [scriptblock]$CommandBlock ) $prevEap = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $lines = & $CommandBlock 2>&1 $textLines = @() foreach ($line in $lines) { $text = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.ToString() } else { [string]$line } $textLines += $text Write-Host $text } $script:LastDevNativeOutput = ($textLines -join [Environment]::NewLine) return $LASTEXITCODE } finally { $ErrorActionPreference = $prevEap } } function Invoke-NpmScript { param( [Parameter(Mandatory = $true)] [string]$ScriptName, [Parameter(Mandatory = $true)] [string]$FailMessage ) $code = Invoke-DevNativeCommand { npm run $ScriptName } if ($code -ne 0) { throw $FailMessage } } function Ensure-DevDirs { foreach ($dir in @($script:DevConfig.DevDir, $script:DevConfig.PidDir, $script:DevConfig.LogDir)) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } } function Stop-PortListeners { param([int[]]$Ports) foreach ($port in $Ports) { $killed = @() try { $conns = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue foreach ($conn in $conns) { $processId = $conn.OwningProcess if ($processId -and $processId -gt 0 -and $killed -notcontains $processId) { Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue $killed += $processId Write-DevLog "freed port $port pid=$processId" "Ok" } } } catch { $lines = netstat -ano | Select-String ":$port\s" foreach ($line in $lines) { if ($line -match '\s+(\d+)\s*$') { $processId = [int]$Matches[1] if ($processId -gt 0 -and $killed -notcontains $processId) { Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue $killed += $processId Write-DevLog "freed port $port pid=$processId (netstat)" "Ok" } } } } } } function Stop-PidFileProcesses { Ensure-DevDirs $pidFiles = Get-ChildItem -Path $script:DevConfig.PidDir -Filter "*.pid" -ErrorAction SilentlyContinue foreach ($file in $pidFiles) { $raw = Get-Content $file.FullName -ErrorAction SilentlyContinue | Select-Object -First 1 if ($raw -match '^\d+$') { $procId = [int]$raw Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue Write-DevLog "stopped $($file.BaseName) pid=$procId" "Ok" } Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue } } function Get-ProjectNodeProcessIds { param([string]$Root) $escaped = [regex]::Escape($Root) $ids = @() try { Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine -match $escaped } | ForEach-Object { $ids += $_.ProcessId } } catch { Write-DevLog "skip node query by path (no WMI)" "Warn" } return @($ids | Select-Object -Unique) } function Stop-ProjectNodeProcesses { param([string]$Root) foreach ($procId in (Get-ProjectNodeProcessIds -Root $Root)) { Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue Write-DevLog "stopped node pid=$procId" "Ok" } } function Wait-DevProjectProcessesGone { param( [string]$Root, [int]$TimeoutSec = 20 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { $remaining = Get-ProjectNodeProcessIds -Root $Root if ($remaining.Count -eq 0) { return $true } foreach ($procId in $remaining) { Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue Write-DevLog "re-stop node pid=$procId" "Warn" } Start-Sleep -Milliseconds 800 } $left = Get-ProjectNodeProcessIds -Root $Root if ($left.Count -gt 0) { Write-DevLog "dev processes still running pid=$($left -join ',')" "Warn" return $false } return $true } function Test-PrismaEngineWritable { param([string]$Root) $engine = Join-Path $Root "node_modules\.prisma\client\query_engine-windows.dll.node" if (-not (Test-Path $engine)) { return $true } try { $stream = [System.IO.File]::Open( $engine, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None ) $stream.Close() return $true } catch { return $false } } function Wait-PrismaEngineWritable { param( [string]$Root, [int]$TimeoutSec = 25 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { if (Test-PrismaEngineWritable -Root $Root) { return $true } Start-Sleep -Milliseconds 600 } return $false } function Clear-PrismaEngineTempFiles { param([string]$Root) $clientDir = Join-Path $Root "node_modules\.prisma\client" if (-not (Test-Path $clientDir)) { return } Get-ChildItem -Path $clientDir -Filter "query_engine*.tmp*" -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue Write-DevLog "removed stale prisma tmp: $($_.Name)" "Ok" } } function Invoke-PrismaGenerateSafe { param( [string]$Root, [int]$MaxAttempts = 5 ) Clear-PrismaEngineTempFiles -Root $Root for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { if ($attempt -gt 1) { Stop-AllDevProcesses } if (-not (Wait-DevProjectProcessesGone -Root $Root -TimeoutSec 15)) { throw "dev processes still hold Prisma engine (run npm run dev:stop first)" } if (-not (Wait-PrismaEngineWritable -Root $Root -TimeoutSec 12)) { Write-DevLog "query_engine DLL locked, retry $($attempt) of $($MaxAttempts)..." "Warn" Start-Sleep -Seconds ([Math]::Min($attempt * 2, 10)) continue } Push-Location $Root try { $exitCode = Invoke-DevNativeCommand { npx prisma generate } if ($exitCode -eq 0) { return } $output = $script:LastDevNativeOutput } finally { Pop-Location } if ($output -notmatch 'EPERM|operation not permitted|EBUSY|being used by another process') { throw "prisma generate failed" } Write-DevLog "prisma generate EPERM, retry $($attempt) of $($MaxAttempts)..." "Warn" Start-Sleep -Seconds ([Math]::Min($attempt * 2, 10)) } throw "prisma generate failed: query_engine DLL locked (run npm run dev:stop first)" } function Clear-DevCaches { param([string]$Root) $targets = @( (Join-Path $Root ".next"), (Join-Path $Root "tsconfig.tsbuildinfo"), (Join-Path $Root "node_modules/.cache"), (Join-Path $Root "node_modules/.vite") ) foreach ($path in $targets) { if (Test-Path $path) { Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue Write-DevLog "removed cache: $path" "Ok" } } } function Test-TcpPortQuick { param( [string]$HostName, [int]$Port, [int]$TimeoutMs = 800 ) $client = $null try { $client = New-Object System.Net.Sockets.TcpClient $connect = $client.BeginConnect($HostName, $Port, $null, $null) $ok = $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false) if (-not $ok) { return $false } $client.EndConnect($connect) return $true } catch { return $false } finally { if ($client) { $client.Close() } } } function Import-DevEnvFiles { param([string]$Root) foreach ($file in @( (Join-Path $Root ".env"), (Join-Path $Root ".dev/infra.env") )) { if (-not (Test-Path $file)) { continue } Get-Content $file | ForEach-Object { if ($_ -match '^\s*(#|$)') { return } $idx = $_.IndexOf('=') if ($idx -gt 0) { $k = $_.Substring(0, $idx).Trim() $v = $_.Substring($idx + 1) Set-Item -Path "env:$k" -Value $v } } } } function Test-RedisReachableQuick { param([int]$TimeoutMs = 800) $url = $env:REDIS_URL if (-not $url) { return $false } if ($url -match 'redis://([^:/]+):(\d+)') { $hostName = $Matches[1] if ($hostName -eq "localhost") { $hostName = "127.0.0.1" } return Test-TcpPortQuick -HostName $hostName -Port ([int]$Matches[2]) -TimeoutMs $TimeoutMs } return $false } function Wake-WslRedisForward { if ($env:OS -notmatch "Windows") { return } $distro = Get-DevWslDistro if (-not $distro) { return } $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" wsl.exe -d $distro -e redis-cli ping 2>&1 | Out-Null $ErrorActionPreference = $prev } function Invoke-FlushRedis { param([string]$Root) Push-Location $Root try { Import-DevEnvFiles -Root $Root if (-not (Test-RedisReachableQuick -TimeoutMs 800)) { Wake-WslRedisForward if (-not (Test-RedisReachableQuick -TimeoutMs 1200)) { Write-DevLog "redis flush skipped (port closed, <2s)" "Warn" return } } $prev = $ErrorActionPreference $ErrorActionPreference = "Continue" npx tsx scripts/dev/flush-redis.ts *> $null $code = $LASTEXITCODE $ErrorActionPreference = $prev if ($code -ne 0) { Write-DevLog "redis flush skipped (redis not up?)" "Warn" } else { Write-DevLog "redis flushed" "Ok" } } catch { Write-DevLog "redis flush skipped (redis not up?)" "Warn" } finally { Pop-Location } } function Wait-HttpOk { param( [string]$Url, [int]$TimeoutSec = 120 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { try { $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 500) { Write-DevLog "$Url OK (HTTP $($resp.StatusCode))" "Ok" return $true } } catch { Start-Sleep -Seconds 2 } } throw "timeout waiting for $Url (${TimeoutSec}s)" } function Start-DevBackgroundProcess { param( [string]$Name, [string]$Command, [string]$Root ) Ensure-DevDirs $logFile = Join-Path $script:DevConfig.LogDir "$Name.log" $pidFile = Join-Path $script:DevConfig.PidDir "$Name.pid" $rootEsc = $Root.Replace("'", "''") $logEsc = $logFile.Replace("'", "''") $infraEnv = Join-Path $Root ".dev/infra.env" $rootEnv = Join-Path $Root ".env" $envLoad = "" if (Test-Path $rootEnv) { $rootEnvEsc = $rootEnv.Replace("'", "''") $envLoad += @" Get-Content '$rootEnvEsc' | ForEach-Object { if (`$_ -match '^\s*(#|$)') { return } `$idx = `$_.IndexOf('=') if (`$idx -gt 0) { `$k = `$_.Substring(0, `$idx).Trim() `$v = `$_.Substring(`$idx + 1) Set-Item -Path env:`$k -Value `$v } }; "@ } if (Test-Path $infraEnv) { $infraEsc = $infraEnv.Replace("'", "''") $envLoad += @" Get-Content '$infraEsc' | ForEach-Object { if (`$_ -match '^\s*(#|$)') { return } `$idx = `$_.IndexOf('=') if (`$idx -gt 0) { `$k = `$_.Substring(0, `$idx).Trim() `$v = `$_.Substring(`$idx + 1) Set-Item -Path env:`$k -Value `$v } }; "@ } # 新进程启动时清空旧日志,避免健康检查误读历史 fail-fast Set-Content -Path $logFile -Value "" -Encoding utf8 $psCommand = "${envLoad}Set-Location '$rootEsc'; `$ErrorActionPreference='Continue'; $Command *>&1 | ForEach-Object { `"`$_`" | Out-File -LiteralPath '$logEsc' -Append -Encoding utf8 }" $proc = Start-Process -FilePath "powershell.exe" ` -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $psCommand ` -PassThru ` -WindowStyle Hidden Set-Content -Path $pidFile -Value $proc.Id -Encoding ascii Write-DevLog "started $Name pid=$($proc.Id) log=$logFile" "Ok" return $proc.Id } function Get-DevLogLatestRunText { param([string]$LogFile) if (-not (Test-Path $LogFile)) { return "" } $lines = Get-Content $LogFile -ErrorAction SilentlyContinue if (-not $lines) { return "" } $startIdx = 0 for ($i = $lines.Count - 1; $i -ge 0; $i--) { if ($lines[$i] -match '^> chajia@') { $startIdx = $i break } } return ($lines[$startIdx..($lines.Count - 1)] -join "`n") } function Assert-DevWorkerHealthy { param( [string]$Name, [int]$WaitSeconds = 3 ) Ensure-DevDirs Start-Sleep -Seconds $WaitSeconds $pidFile = Join-Path $script:DevConfig.PidDir "$Name.pid" $logFile = Join-Path $script:DevConfig.LogDir "$Name.log" $latest = Get-DevLogLatestRunText -LogFile $logFile if (Test-Path $pidFile) { $workerPid = [int](Get-Content $pidFile -ErrorAction SilentlyContinue) $proc = Get-Process -Id $workerPid -ErrorAction SilentlyContinue if (-not $proc) { $hint = Get-DevWorkerFailureHint -LogFile $logFile -RunText $latest throw "$Name process exited. $hint Log: $logFile" } } if (-not $latest) { return } if ($latest -match "MOTHERSHIP_QUOTE_URLS|缺少 RPA selector|assertRpaWorkerEnv|启动失败") { $hint = Get-DevWorkerFailureHint -LogFile $logFile -RunText $latest throw "$Name failed to start. $hint Log: $logFile" } } function Get-DevWorkerFailureHint { param( [string]$LogFile, [string]$RunText = "" ) if (-not $RunText -and (Test-Path $LogFile)) { $RunText = Get-DevLogLatestRunText -LogFile $LogFile } if (-not $RunText) { return "Configure RPA env per docs/rpa-recording-sop.md." } if ($RunText -match "MOTHERSHIP_QUOTE_URLS") { return "Set MOTHERSHIP_QUOTE_URLS (headed recording required, see docs/rpa-recording-sop.md)." } if ($RunText -match "缺少 RPA selector env:\s*RPA_SELECTOR_FASTEST_OPTION\s*$") { return "Update code: RPA_SELECTOR_FASTEST_OPTION is optional; run git pull or rebuild." } if ($RunText -match "缺少 RPA selector env") { return "Set required RPA_SELECTOR_* from Playwright recording (FASTEST optional)." } return "Check RPA env (MOTHERSHIP_QUOTE_URLS + RPA_SELECTOR_*)." } function Get-DevWslDistro { $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $raw = & wsl.exe -l -q 2>&1 | Out-String $ErrorActionPreference = $prev # wsl -l -q emits UTF-16LE; strip null bytes for PowerShell 5.1 $text = $raw -replace "`0", "" $distros = @() foreach ($line in ($text -split "`r?`n")) { $name = ($line -replace '^\*\s*', '').Trim() if ($name -and $name -notmatch '^(docker-desktop|docker-desktop-data)$') { $distros += $name } } $ubuntu = $distros | Where-Object { $_ -match 'Ubuntu' } | Select-Object -First 1 if ($ubuntu) { return $ubuntu } if ($distros.Count -gt 0) { return $distros[0] } return $null } function Ensure-DevWslDistro { $distro = Get-DevWslDistro if ($distro) { return $distro } Write-DevLog "no Linux WSL distro, installing Ubuntu-22.04 (one-time)..." "Warn" $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" wsl --install -d Ubuntu-22.04 --no-launch 2>&1 | Out-Null $ErrorActionPreference = $prev $distro = Get-DevWslDistro if (-not $distro) { throw "WSL Linux distro required. Run: wsl --install -d Ubuntu-22.04" } return $distro } function Invoke-DevWsl { param([string]$BashCommand) $distro = Ensure-DevWslDistro $prev = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" wsl -d $distro -e bash -lc $BashCommand 2>&1 | Out-Null $code = $LASTEXITCODE $ErrorActionPreference = $prev return $code } function Stop-AllDevProcesses { Write-DevLog "stopping dev processes..." Stop-PidFileProcesses Stop-WslKeepAlive $ports = @($script:DevConfig.NextPort) + $script:DevConfig.ExtraPorts Stop-PortListeners -Ports $ports Stop-ProjectNodeProcesses -Root $script:DevConfig.ProjectRoot $null = Wait-DevProjectProcessesGone -Root $script:DevConfig.ProjectRoot -TimeoutSec 12 Start-Sleep -Milliseconds 800 } function Start-WslKeepAlive { param([string]$Distro) Ensure-DevDirs $pidFile = Join-Path $script:DevConfig.PidDir "wsl-keepalive.pid" if (Test-Path $pidFile) { $oldPid = [int](Get-Content $pidFile -ErrorAction SilentlyContinue) if ($oldPid -gt 0 -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { return } } $proc = Start-Process -FilePath "wsl.exe" ` -ArgumentList "-d", $Distro, "-e", "sleep", "infinity" ` -WindowStyle Hidden -PassThru Set-Content -Path $pidFile -Value $proc.Id -Encoding ascii Write-DevLog "wsl keepalive pid=$($proc.Id)" "Ok" } function Stop-WslKeepAlive { $pidFile = Join-Path $script:DevConfig.PidDir "wsl-keepalive.pid" if (-not (Test-Path $pidFile)) { return } $keepPid = [int](Get-Content $pidFile -ErrorAction SilentlyContinue) if ($keepPid -gt 0) { Stop-Process -Id $keepPid -Force -ErrorAction SilentlyContinue } Remove-Item $pidFile -Force -ErrorAction SilentlyContinue } # Resolve LAN IPv4 for colleague testing (skip WSL/Hyper-V virtual adapters). function Resolve-DevLanHost { param( [string]$Configured = "auto" ) if ($Configured -and $Configured -ne "auto") { return $Configured.Trim() } $virtualPattern = 'WSL|Hyper-V|vEthernet|Docker|VMware|Loopback|Teredo|isatap|Npcap|VirtualBox' $addrs = @() try { $addrs = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.InterfaceAlias -notmatch $virtualPattern }) } catch { return $null } if ($addrs.Count -eq 0) { return $null } $preferred = @($addrs | Where-Object { $_.InterfaceAlias -match 'WLAN|Wi-?Fi|Wireless|Ethernet|以太网|无线' } | Sort-Object InterfaceMetric)[0] if ($preferred) { return $preferred.IPAddress } return ($addrs | Sort-Object InterfaceMetric)[0].IPAddress } # 内网测试:允许同事访问本机 Next 端口(需管理员权限时仅告警) function Ensure-DevLanFirewall { $port = $script:DevConfig.NextPort $ruleName = "chajia-dev-tcp-$port" $existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue if ($existing) { return } try { New-NetFirewallRule ` -DisplayName $ruleName ` -Direction Inbound ` -LocalPort $port ` -Protocol TCP ` -Profile Any ` -Action Allow ` -ErrorAction Stop | Out-Null Write-DevLog "firewall inbound TCP $port allowed ($ruleName)" "Ok" } catch { Write-DevLog "firewall: could not open TCP $port (run dev:start as Administrator once)" "Warn" } } . (Join-Path $PSScriptRoot "ensure-infra.ps1")