You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

678 lines
20 KiB

# 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")