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 = 'migrate'; Port = 0; Health = $false } apihost = @{ Exe = 'apihost'; Port = 4081; Health = $true } im = @{ Exe = 'im'; Port = 4082; Health = $true } web = @{ Bun = 'web'; Port = 5173; Health = $false } } $Order = @('migrate', 'apihost', 'im', 'web') $Backends = @('migrate', 'apihost', 'im') $Aliases = @{ api = 'apihost' chat = 'im' ui = 'web' } $Urls = [ordered]@{ apihost = 'http://127.0.0.1:4081 (Swagger: /api/docs)' im = 'ws://127.0.0.1:4082' web = 'http://127.0.0.1:5173' } $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 'migrate.exe' if (-not (Test-Path -LiteralPath $exe)) { throw "缺少 $exe,请先执行 just build" } Write-Info '[migrate] 同步数据库 schema ...' $proc = Start-Process -FilePath $exe -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" if ($svc.ContainsKey('Bun')) { $exe = (Get-Command bun.exe -ErrorAction SilentlyContinue).Source if (-not $exe) { throw '未找到 bun.exe,请先安装 bun 并加入 PATH' } $dir = Join-Path $Root $svc.Bun if (-not (Test-Path -LiteralPath $dir)) { throw "缺少目录 $dir" } $procId = Start-Daemon -Name $Name -Exe $exe -Arguments 'run dev' -WorkingDirectory $dir -Out $out -Err $err } else { $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 '' -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 ($key -eq 'frontends') { foreach ($n in @('web')) { if (-not $names.Contains($n)) { $names.Add($n) } } continue } if (-not $Services.Contains($key)) { throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/hook/ui/backends/frontends)" } 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', 'bun', '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) ==" foreach ($name in $Backends) { $exe = Join-Path $BinDir ($Services[$name].Exe + '.exe') if (Test-Path -LiteralPath $exe) { Write-Info (" {0,-12} OK" -f $name) } else { Write-Warn (" {0,-12} 未构建(just build)" -f $name); $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 }