# lib/common.ps1 — app-cli 公共函数 # # 约定:调用方脚本先设置 $CliRoot(app-cli 根目录),再点源引入本文件: # $CliRoot = Split-Path -Parent $PSScriptRoot # . (Join-Path $CliRoot 'lib\common.ps1') # # 本文件不启动任何服务,只提供:路径解析 / 配置读取 / 端口探测 / 进程登记 / HTTP 探测。 # 兼容 Windows PowerShell 5.1 与 PowerShell 7。 if (-not $CliRoot) { throw 'common.ps1:调用方必须先设置 $CliRoot' } $script:AppCliRoot = $CliRoot $LogDir = Join-Path $CliRoot '.logs' $RunDir = Join-Path $CliRoot '.run' $BinDir = Join-Path $CliRoot '.bin' foreach ($d in @($LogDir, $RunDir, $BinDir)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } # ---------------------------------------------------------------- 输出 function Write-Step { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan } function Write-Ok { param([string]$Msg) Write-Host " [OK] $Msg" -ForegroundColor Green } function Write-Note { param([string]$Msg) Write-Host " [--] $Msg" -ForegroundColor Gray } function Write-Warn { param([string]$Msg) Write-Host " [!!] $Msg" -ForegroundColor Yellow } function Write-Fail { param([string]$Msg) Write-Host " [XX] $Msg" -ForegroundColor Red } # ---------------------------------------------------------------- 配置 # Import-LocalEnv:读取 KEY=VALUE 形式的本地配置(config/local.env)。 # 支持 # 注释、空行、值两侧引号;文件不存在时返回空表(全部走默认值)。 function Import-LocalEnv { param([string]$Path) $map = @{} if (-not (Test-Path $Path)) { return $map } foreach ($line in (Get-Content -Path $Path -Encoding UTF8)) { $t = $line.Trim() if ($t -eq '' -or $t.StartsWith('#')) { continue } $i = $t.IndexOf('=') if ($i -lt 1) { continue } $k = $t.Substring(0, $i).Trim() $v = $t.Substring($i + 1).Trim() if ($v.Length -ge 2 -and $v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) } $map[$k] = $v } return $map } # Get-Setting:取本地配置项,缺省或空串时回落 $Default。 function Get-Setting { param($EnvMap, [string]$Key, [string]$Default) if ($EnvMap -and $EnvMap.ContainsKey($Key)) { $v = $EnvMap[$Key] if ($v -ne '') { return $v } } return $Default } # Get-YamlScalar:从 yml 里取「某个顶层 section 下某个键」的标量值(只处理本项目用到的简单结构)。 function Get-YamlScalar { param([string]$Path, [string]$Section, [string]$Key) if (-not (Test-Path $Path)) { return '' } $inSection = $false foreach ($line in (Get-Content -Path $Path -Encoding UTF8)) { if ($line -match '^([A-Za-z_][A-Za-z0-9_]*)\s*:') { $inSection = ($Matches[1] -eq $Section) continue } if (-not $inSection) { continue } if ($line -match '^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$') { if ($Matches[1] -ne $Key) { continue } $v = $Matches[2] $h = $v.IndexOf('#') if ($h -ge 0) { $v = $v.Substring(0, $h) } return $v.Trim().Trim('"').Trim("'") } } return '' } # ---------------------------------------------------------------- 仓库定位 # Resolve-Services:确定 backend / frontend / vision 三个仓库的实际路径。 # 优先级:config/local.env 显式配置 > 与 app-cli 同级的约定目录。 # 用「标记文件」校验,避免路径写错后启动到一个空目录。 function Resolve-Services { param($EnvMap, [string]$Root) $parent = Split-Path -Parent $Root $defs = @( @{ Name = 'backend'; Key = 'BACKEND_DIR'; Default = (Join-Path $parent 'backend'); Marker = 'cmd\server\main.go' }, @{ Name = 'frontend'; Key = 'FRONTEND_DIR'; Default = (Join-Path $parent 'test\test'); Marker = 'astro.config.mjs' }, @{ Name = 'vision'; Key = 'VISION_DIR'; Default = (Join-Path $parent 'fashion-siglip'); Marker = 'app.py' } ) $result = @{} foreach ($d in $defs) { $path = Get-Setting $EnvMap $d.Key $d.Default $path = $path.TrimEnd('\', '/') $marker = Join-Path $path $d.Marker $result[$d.Name] = [PSCustomObject]@{ Name = $d.Name Path = $path Marker = $marker IsValid = (Test-Path $marker) } } return [PSCustomObject]@{ Backend = $result['backend'] Frontend = $result['frontend'] Vision = $result['vision'] } } # ---------------------------------------------------------------- 端口与进程 function Test-PortInUse { param([int]$Port) $c = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue return [bool]$c } function Get-PortOwnerPid { param([int]$Port) $c = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $c) { return 0 } return [int]$c.OwningProcess } function Get-PidFile { param([string]$Name) return (Join-Path $RunDir ($Name + '.pid')) } # Save-ServicePid:登记本脚本启动的进程,供 stop.ps1 / status.ps1 使用。 function Save-ServicePid { param([string]$Name, [int]$ProcessId) Set-Content -Path (Get-PidFile $Name) -Value $ProcessId -Encoding ASCII } # Get-ServicePid:读取登记的 PID;进程已不存在时清掉登记并返回 0。 function Get-ServicePid { param([string]$Name) $f = Get-PidFile $Name if (-not (Test-Path $f)) { return 0 } $raw = (Get-Content -Path $f -ErrorAction SilentlyContinue | Select-Object -First 1) $id = 0 if ($raw -and [int]::TryParse($raw.Trim(), [ref]$id)) { $p = Get-Process -Id $id -ErrorAction SilentlyContinue if ($p) { return $id } } Remove-Item $f -Force -ErrorAction SilentlyContinue return 0 } # Stop-PidTree:杀掉进程及其子进程(node/go 会派生,必须连树一起杀)。 function Stop-PidTree { param([int]$ProcessId) if ($ProcessId -le 0) { return $false } $p = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue if (-not $p) { return $false } & taskkill.exe /PID $ProcessId /T /F 2>&1 | Out-Null Start-Sleep -Milliseconds 300 return $true } # Stop-NamedService:按登记名停服务,顺带清掉 PID 文件。 function Stop-NamedService { param([string]$Name) $id = Get-ServicePid $Name if ($id -le 0) { return $false } $killed = Stop-PidTree $id Remove-Item (Get-PidFile $Name) -Force -ErrorAction SilentlyContinue return $killed } # Start-LoggedProcess:后台启动并重定向日志,成功后登记 PID。 function Start-LoggedProcess { param( [string]$Name, [string]$FilePath, [string[]]$ArgumentList = @(), [string]$WorkingDirectory ) $out = Join-Path $LogDir ($Name + '.log') $err = Join-Path $LogDir ($Name + '.err.log') foreach ($f in @($out, $err)) { if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue } } # 注意:Start-Process 的 -ArgumentList 传空数组会抛参数校验异常("该参数为 Null、为空"), # 所以无参进程(例如后端的编译产物)必须整个不传这个参数。 $params = @{ FilePath = $FilePath WorkingDirectory = $WorkingDirectory RedirectStandardOutput = $out RedirectStandardError = $err WindowStyle = 'Hidden' PassThru = $true } if ($null -ne $ArgumentList -and $ArgumentList.Count -gt 0) { $params['ArgumentList'] = $ArgumentList } $p = Start-Process @params Save-ServicePid -Name $Name -ProcessId $p.Id return $p } # ---------------------------------------------------------------- HTTP # Test-TcpConnect:TCP 连通性探测(数据库这类非 HTTP 服务用)。 function Test-TcpConnect { param([string]$ComputerName, [int]$Port, [int]$TimeoutMs = 3000) $client = New-Object System.Net.Sockets.TcpClient try { $iar = $client.BeginConnect($ComputerName, $Port, $null, $null) if (-not $iar.AsyncWaitHandle.WaitOne($TimeoutMs)) { return $false } $client.EndConnect($iar) return $true } catch { return $false } finally { $client.Close() } } # Wait-HttpOk:轮询直到返回 2xx/3xx,超时返回 $false。 function Wait-HttpOk { param([string]$Url, [int]$TimeoutSec = 90) $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline) { try { $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) { return $true } } catch { } Start-Sleep -Milliseconds 700 } return $false } # Get-CorsOrigin:带 Origin 头请求一次,返回后端回显的 Access-Control-Allow-Origin。 # 返回空串表示后端没放行该来源(浏览器会整条拦掉,页面表现为「无数据」)。 function Get-CorsOrigin { param([string]$Url, [string]$Origin) try { $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 8 -Headers @{ Origin = $Origin } -ErrorAction Stop $acao = $r.Headers['Access-Control-Allow-Origin'] if ($acao -is [array]) { $acao = $acao[0] } if ($null -eq $acao) { return '' } return [string]$acao } catch { return '' } } # ---------------------------------------------------------------- 日志 function Show-LogTail { param([string]$Name, [int]$Lines = 15) foreach ($suffix in @('.log', '.err.log')) { $f = Join-Path $LogDir ($Name + $suffix) if (-not (Test-Path $f)) { continue } $content = Get-Content -Path $f -Tail $Lines -ErrorAction SilentlyContinue if (-not $content) { continue } Write-Host (" --- " + $Name + $suffix + " ---") -ForegroundColor DarkGray foreach ($l in $content) { Write-Host (" " + $l) -ForegroundColor DarkGray } } } # Get-ServiceStatus:给 status.ps1 用的单服务状态。 function Get-ServiceStatus { param([string]$Name, [int]$Port, [string]$HealthUrl) $id = Get-ServicePid $Name $listening = Test-PortInUse $Port $owner = 0 if ($listening) { $owner = Get-PortOwnerPid $Port } $healthy = $false if ($listening -and $HealthUrl -ne '') { $healthy = Wait-HttpOk -Url $HealthUrl -TimeoutSec 3 } return [PSCustomObject]@{ Name = $Name Port = $Port Pid = $id Listening = $listening OwnerPid = $owner Healthy = $healthy } }