10890d2
param(
[Parameter(Position = 0)][string]$Command = 'status',
[Parameter(Position = 1, ValueFromRemainingArguments = $true)][string[]]$Rest
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
# 二进制目录跟随 CARGO_TARGET_DIR(cargo 约定:相对路径按调用时的工作目录解析,
# just 在仓库根目录调用 cargo,所以这里也按 $Root 解析)。
# $DevDir 保持在仓库内的 target\dev:日志 / pid / 本地仓库不随 target 目录搬家。
function Resolve-TargetDir {
$raw = $env:CARGO_TARGET_DIR
if (-not $raw) { return (Join-Path $Root 'target') }
if ($raw -eq '~') { return $HOME }
if ($raw.StartsWith('~\') -or $raw.StartsWith('~/')) { return (Join-Path $HOME $raw.Substring(2)) }
if ([System.IO.Path]::IsPathRooted($raw)) { return $raw }
return (Join-Path $Root $raw)
}
$TargetDir = Resolve-TargetDir
$DevDir = Join-Path $Root 'target\dev'
$LogDir = Join-Path $DevDir 'logs'
$PidDir = Join-Path $DevDir 'pids'
$BinDir = Join-Path $TargetDir 'debug'
$DevHostKey = Join-Path $DevDir 'ssh_host_ed25519'
$Services = [ordered]@{
migrate = @{ Exe = 'kenapi'; Args = 'migrate'; Port = 0; Health = $false }
apihost = @{ Exe = 'kenapi'; Args = 'serve'; Port = 7051; Health = $true }
}
$Order = @('migrate', 'apihost')
$Backends = @('migrate', 'apihost')
$Aliases = @{
api = 'apihost'
}
$Urls = [ordered]@{
apihost = 'http://127.0.0.1:7051 (Swagger: /api/docs)'
}
$Infra = @(
@{ Name = 'postgresql'; Port = 5432 },
@{ Name = 'redis8'; Port = 6379 },
@{ Name = 'rustfs'; Port = 9000 },
@{ Name = 'livekit'; Port = 7880 }
)
function Write-Info { param([string]$Message) Write-Host $Message }
function Write-Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Write-Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Ensure-Dir {
foreach ($dir in @($DevDir, $LogDir, $PidDir)) {
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
}
}
function Test-Port {
param([int]$Port, [int]$TimeoutMs = 1000)
if (-not $Port) { return $false }
$client = New-Object System.Net.Sockets.TcpClient
try {
$task = $client.ConnectAsync('127.0.0.1', $Port)
if (-not $task.Wait($TimeoutMs)) { return $false }
return $client.Connected
} catch {
return $false
} finally {
$client.Dispose()
}
}
function Test-Health {
param([string]$Name)
$svc = $Services[$Name]
if ($svc.Port -eq 0) { return $true }
if ($svc.Health) {
try {
$res = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 -Uri ("http://127.0.0.1:{0}/healthz" -f $svc.Port)
return ($res.StatusCode -eq 200)
} catch {
return $false
}
}
return (Test-Port $svc.Port)
}
function Get-PidPath { param([string]$Name) Join-Path $PidDir ("$Name.pid") }
function Register-ServiceProcess {
param([string]$Name, [int]$ProcId)
Set-Content -LiteralPath (Get-PidPath $Name) -Value $ProcId -Encoding ascii
}
function Get-RunningPid {
param([string]$Name)
$file = Get-PidPath $Name
if (-not (Test-Path -LiteralPath $file)) { return 0 }
$procId = 0
if (-not [int]::TryParse((Get-Content -LiteralPath $file -First 1).Trim(), [ref]$procId)) { return 0 }
if (Get-Process -Id $procId -ErrorAction SilentlyContinue) { return $procId }
return 0
}
function Get-PortOwner {
param([int]$Port)
if (-not $Port) { return 0 }
$conn = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue | Select-Object -First 1
if ($conn) { return [int]$conn.OwningProcess }
return 0
}
function Test-OwnedByRepo {
param([int]$ProcId)
$proc = Get-Process -Id $ProcId -ErrorAction SilentlyContinue
if (-not $proc) { return $false }
$path = ''
try { $path = $proc.Path } catch { }
if ($path) {
if ($path.StartsWith($Root, [StringComparison]::OrdinalIgnoreCase)) { return $true }
if ($path.StartsWith($BinDir, [StringComparison]::OrdinalIgnoreCase)) { return $true }
}
try {
$cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$ProcId" -ErrorAction SilentlyContinue).CommandLine
if ($cmd -and ($cmd.Contains($Root) -or $cmd.Contains($BinDir))) { return $true }
} catch { }
return $false
}
function Ensure-HostKey {
if (Test-Path -LiteralPath $DevHostKey) { return $DevHostKey }
Ensure-Dir
Write-Info "[hostkey] 生成本地 SSH host key: $DevHostKey"
& ssh-keygen -q -t ed25519 -f $DevHostKey -N '""' -C 'gitdata-dev' | Out-Null
if (-not (Test-Path -LiteralPath $DevHostKey)) { throw 'ssh-keygen 执行失败' }
return $DevHostKey
}
function Set-ServiceEnv {
$hostKey = Ensure-HostKey
$env:APP_GIT__SSH__HOST_KEY_PATH = $hostKey
foreach ($dir in @('C:\opt\gitdata\repos', 'C:\opt\gitdata\objects')) {
if (-not (Test-Path -LiteralPath $dir)) {
try { New-Item -ItemType Directory -Force -Path $dir | Out-Null } catch { }
}
}
if (-not (Test-Path -LiteralPath 'C:\opt\gitdata\repos')) {
Write-Warn '[warn] 无法创建 C:\opt\gitdata\repos,请在 config/local.toml 或 APP_GIT__REPO_ROOT 指定可写目录'
}
}
function Invoke-Migrate {
Ensure-Dir
Set-ServiceEnv
$exe = Join-Path $BinDir 'kenapi.exe'
if (-not (Test-Path -LiteralPath $exe)) { throw "缺少 $exe,请先执行 just build" }
Write-Info '[migrate] 同步数据库 schema ...'
$proc = Start-Process -FilePath $exe -ArgumentList 'migrate' -WorkingDirectory $Root -PassThru -Wait `
-RedirectStandardOutput (Join-Path $LogDir 'migrate.log') `
-RedirectStandardError (Join-Path $LogDir 'migrate.err.log') `
-WindowStyle Hidden
if ($proc.ExitCode -ne 0) { throw "migrate 失败 (exit=$($proc.ExitCode)),见 just logs migrate" }
Write-Info '[migrate] 完成'
}
function Start-Daemon {
param([string]$Name, [string]$Exe, [string]$Arguments, [string]$WorkingDirectory, [string]$Out, [string]$Err)
# 启动器由 cmd.exe 解释,必须用系统 ANSI 编码(本机为 GBK)写,
# 不能用 -Encoding ascii:仓库路径含非 ASCII(如 C:\工程)会被写成 ?,
# cmd.exe 找不到 exe,服务静默起不来。
$launcher = Join-Path $PidDir "$Name.cmd"
$lines = @(
'@echo off'
'set "APP_GIT__SSH__HOST_KEY_PATH=' + $env:APP_GIT__SSH__HOST_KEY_PATH + '"'
'cd /d "' + $WorkingDirectory + '"'
'"' + $Exe + '" ' + $Arguments + ' 1> "' + $Out + '" 2> "' + $Err + '"'
)
Set-Content -LiteralPath $launcher -Value $lines -Encoding Default
$proc = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"{0}"' -f $launcher)) `
-WorkingDirectory $WorkingDirectory -WindowStyle Hidden -PassThru
return [int]$proc.Id
}
function Start-One {
param([string]$Name)
$svc = $Services[$Name]
$running = Get-RunningPid $Name
if ($running -and (Test-Health $Name)) {
Write-Info "[$Name] 已在运行 (pid $running)"
return $false
}
if ($svc.Port -and (Test-Port $svc.Port)) {
Write-Warn "[$Name] 端口 $($svc.Port) 已被 pid $(Get-PortOwner $svc.Port) 占用,跳过"
return $false
}
Ensure-Dir
Set-ServiceEnv
$out = Join-Path $LogDir "$Name.log"
$err = Join-Path $LogDir "$Name.err.log"
$exe = Join-Path $BinDir ($svc.Exe + '.exe')
if (-not (Test-Path -LiteralPath $exe)) { throw "缺少 $exe,请先执行 just build" }
$procId = Start-Daemon -Name $Name -Exe $exe -Arguments $svc.Args -WorkingDirectory $Root -Out $out -Err $err
Register-ServiceProcess $Name $procId
Write-Info "[$Name] 启动 pid=$procId"
return $true
}
function Wait-Ready {
param([string]$Name, [int]$TimeoutSec = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
if (Test-Health $Name) { return $true }
if (-not (Get-RunningPid $Name)) { return $false }
Start-Sleep -Milliseconds 500
}
return $false
}
function Stop-Stray {
param([string]$Name, [int]$Port)
if (-not $Port) { return }
$owner = Get-PortOwner $Port
if (-not $owner) { return }
if (Test-OwnedByRepo $owner) {
& taskkill /PID $owner /T /F | Out-Null
Write-Info "[$Name] 清理残留监听 $Port (pid $owner)"
} else {
Write-Warn "[$Name] 端口 $Port 仍被 pid $owner 占用(非本仓库进程,未处理)"
}
}
function Stop-One {
param([string]$Name)
$svc = $Services[$Name]
$procId = Get-RunningPid $Name
if ($procId) {
& taskkill /PID $procId /T /F | Out-Null
Write-Info "[$Name] 已停止 (pid $procId)"
} else {
Write-Info "[$Name] 未在运行"
}
Remove-Item -LiteralPath (Get-PidPath $Name) -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $PidDir "$Name.cmd") -Force -ErrorAction SilentlyContinue
Stop-Stray $Name $svc.Port
foreach ($port in @($svc.Extra)) { if ($port) { Stop-Stray $Name $port } }
}
function Resolve-Services {
param([string[]]$Requested)
if (-not $Requested -or $Requested.Count -eq 0) { return $Order }
$names = New-Object System.Collections.Generic.List[string]
foreach ($item in $Requested) {
$raw = $item.Trim()
if (-not $raw) { continue }
foreach ($part in $raw.Split(',')) {
$key = $part.Trim().ToLower()
if (-not $key) { continue }
if ($Aliases.ContainsKey($key)) { $key = $Aliases[$key] }
if ($key -eq 'all') {
foreach ($n in $Order) { if (-not $names.Contains($n)) { $names.Add($n) } }
continue
}
if ($key -eq 'backends') {
foreach ($n in $Backends) { if (-not $names.Contains($n)) { $names.Add($n) } }
continue
}
if (-not $Services.Contains($key)) {
throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/hook/backends)"
}
if (-not $names.Contains($key)) { $names.Add($key) }
}
}
if ($names.Count -eq 0) { return $Order }
return $names.ToArray()
}
function Invoke-Check {
$ok = $true
Write-Info '== 工具链 =='
foreach ($tool in @('cargo', 'ssh-keygen', 'wsl')) {
$found = Get-Command $tool -ErrorAction SilentlyContinue
if ($found) { Write-Info (" {0,-12} OK" -f $tool) } else { Write-Err (" {0,-12} 缺失" -f $tool); $ok = $false }
}
Write-Info '== WSL 基础设施 =='
foreach ($item in $Infra) {
if (Test-Port $item.Port) {
Write-Info (" {0,-12} 127.0.0.1:{1} OK" -f $item.Name, $item.Port)
} else {
Write-Err (" {0,-12} 127.0.0.1:{1} 不可用" -f $item.Name, $item.Port)
$ok = $false
}
}
Write-Info "== 后端二进制 ($BinDir) =="
$exe = Join-Path $BinDir 'kenapi.exe'
if (Test-Path -LiteralPath $exe) { Write-Info (" {0,-12} OK" -f 'kenapi') } else { Write-Warn (" {0,-12} 未构建(just build)" -f 'kenapi'); $ok = $false }
if (-not $ok) {
Write-Info ''
Write-Info 'just infra 启动 WSL 侧 PostgreSQL / Redis / RustFS'
Write-Info 'just build 构建全部后端二进制(前端由 build.rs 自动构建并内嵌)'
}
}
function Invoke-Infra {
param([switch]$Stop)
$verb = 'start'
if ($Stop) { $verb = 'stop' }
Write-Info "[infra] $verb WSL 服务: $($Infra.Name -join ', ')"
& wsl.exe -d Ubuntu -u root -- systemctl $verb @($Infra.Name)
if ($LASTEXITCODE -ne 0) { throw "wsl systemctl $verb 失败,请确认 WSL 发行版 Ubuntu 可用" }
Start-Sleep -Seconds 2
foreach ($item in $Infra) {
$state = 'DOWN'
if (Test-Port $item.Port) { $state = 'OK' }
Write-Info (" {0,-12} 127.0.0.1:{1} {2}" -f $item.Name, $item.Port, $state)
}
}
function Invoke-Up {
param([string[]]$Requested, [switch]$SkipMigrate)
$names = Resolve-Services $Requested
$needsBackend = $false
foreach ($n in $names) { if ($Backends -contains $n) { $needsBackend = $true } }
Set-ServiceEnv
foreach ($item in $Infra) {
if (-not (Test-Port $item.Port)) { Write-Warn "[warn] $($item.Name) 未监听 127.0.0.1:$($item.Port),可执行 just infra" }
}
if ($needsBackend -and -not $SkipMigrate) { Invoke-Migrate }
foreach ($name in $Order) {
if ($names -notcontains $name) { continue }
if ($name -eq 'migrate') { continue }
try {
$started = Start-One $name
} catch {
Write-Err "[$name] 启动失败: $($_.Exception.Message)"
continue
}
if ($started) {
if (Wait-Ready $name 60) { Write-Info "[$name] 就绪" } else { Write-Err "[$name] 60s 内未就绪,见 just logs $name" }
}
}
Write-Info ''
Write-Info '服务地址:'
foreach ($name in $Order) {
if ($names -notcontains $name) { continue }
if ($name -eq 'migrate') { continue }
Write-Info (" {0,-10} {1}" -f $name, $Urls[$name])
}
}
function Invoke-Down {
param([string[]]$Requested)
$names = Resolve-Services $Requested
$reversed = @($Order)
[array]::Reverse($reversed)
foreach ($name in $reversed) {
if ($names -notcontains $name) { continue }
Stop-One $name
}
}
function Invoke-Status {
Write-Info ("{0,-10} {1,-8} {2,-9} {3}" -f 'SERVICE', 'PID', 'PORT', 'STATE')
foreach ($name in $Order) {
if ($name -eq 'migrate') { continue }
$svc = $Services[$name]
$procId = Get-RunningPid $name
$portState = 'closed'
if (Test-Port $svc.Port) { $portState = 'listen' }
$state = 'stopped'
if ($procId) {
if (Test-Health $name) { $state = 'ready' } else { $state = 'starting/fail' }
}
Write-Info ("{0,-10} {1,-8} {2,-9} {3}" -f $name, $procId, $portState, $state)
}
}
function Invoke-Logs {
param([string[]]$Names, [switch]$Follow)
if (-not $Names -or $Names.Count -eq 0) { $Names = @('apihost') }
$target = $Names[0]
if (-not $Services.Contains($target)) { throw "未知服务: $target" }
foreach ($name in $Names) {
foreach ($suffix in @('log', 'err.log')) {
$path = Join-Path $LogDir "$name.$suffix"
if ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).Length -gt 0) {
Write-Info "===== $name.$suffix (tail 80) ====="
Get-Content -LiteralPath $path -Tail 80
}
}
}
if ($Follow) {
$path = Join-Path $LogDir "$target.log"
if (-not (Test-Path -LiteralPath $path)) { throw "没有日志文件: $path" }
Write-Info "===== follow $target.log (Ctrl+C 退出) ====="
Get-Content -LiteralPath $path -Tail 0 -Wait
}
}
function Get-ConfigUrl {
param([string]$Scheme)
$file = Join-Path $Root 'config\local.toml'
if (Test-Path -LiteralPath $file) {
$pattern = '^\s*url\s*=\s*"' + $Scheme + '://([^"]+)"'
foreach ($line in Get-Content -LiteralPath $file) {
if ($line -match $pattern) { return ($Scheme + '://' + $Matches[1]) }
}
}
return ''
}
function Invoke-Psql {
$url = Get-ConfigUrl 'postgres'
if (-not $url) { throw '未能从 config/local.toml 解析 postgres url' }
Write-Info "[psql] $url"
& wsl.exe -d Ubuntu -- psql $url
}
function Invoke-Redis {
$url = Get-ConfigUrl 'redis'
if (-not $url) { throw '未能从 config/local.toml 解析 redis url' }
Write-Info "[redis-cli] $url"
& wsl.exe -d Ubuntu -- redis-cli -u $url
}
try {
switch ($Command.ToLower()) {
'check' { Invoke-Check }
'infra' { Invoke-Infra }
'infra-down' { Invoke-Infra -Stop }
'hostkey' { Write-Info "[hostkey] $(Ensure-HostKey)" }
'migrate' { Invoke-Migrate }
'up' { Invoke-Up -Requested $Rest }
'down' { Invoke-Down -Requested $Rest }
'restart' { Invoke-Down -Requested $Rest; Invoke-Up -Requested $Rest -SkipMigrate }
'status' { Invoke-Status }
'psql' { Invoke-Psql }
'redis' { Invoke-Redis }
'logs' {
$follow = $false
$names = @()
foreach ($item in $Rest) {
if ($item -in @('-f', '--follow')) { $follow = $true } else { $names += $item }
}
Invoke-Logs -Names $names -Follow:$follow
}
default { throw "未知命令: $Command (check/infra/infra-down/hostkey/migrate/up/down/restart/status/logs/psql/redis)" }
}
} catch {
Write-Err "ERROR: $($_.Exception.Message)"
exit 1
}