Files
app-cli/lib/common.ps1
toom1996 e366ee7498 chore: 初始化 app-cli,本地开发环境脚本
- dev/dev.ps1: 一键启动后端(编译后运行)+前端(4321),校验 CORS 与 astro dev 单实例锁
- dev/stop.ps1: 按 PID 登记停止,-Force 清残留
- dev/status.ps1: 端口/PID/健康检查一览
- dev/doctor.ps1: 工具链/路径/依赖/数据库/端口/CORS 体检
- vision/vision.ps1: CLIP 向量服务起停
- lib/common.ps1: 路径探测、端口、进程登记、HTTP 探测
- config/local.env.example: 路径与端口覆盖模板
2026-10-22 10:37:59 +08:00

289 lines
11 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
}
}