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: 路径与端口覆盖模板
This commit is contained in:
288
lib/common.ps1
Normal file
288
lib/common.ps1
Normal file
@ -0,0 +1,288 @@
|
||||
# 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user