feat(dev): add development orchestration scripts for local environment setup
ZhenYi committed
Added scripts/dev.ps1 scripts/dev.ps1 +484 −0 View scripts/dev.ps1
@@ -0,0 +1,484 @@
1param(
2 [Parameter(Position = 0)][string]$Command = 'status',
3 [Parameter(Position = 1, ValueFromRemainingArguments = $true)][string[]]$Rest
4)
5
6$ErrorActionPreference = 'Stop'
7
8$Root = Split-Path -Parent $PSScriptRoot
9$DevDir = Join-Path $Root 'target\dev'
10$LogDir = Join-Path $DevDir 'logs'
11$PidDir = Join-Path $DevDir 'pids'
12$BinDir = Join-Path $Root 'target\debug'
13$DevHostKey = Join-Path $DevDir 'ssh_host_ed25519'
14
15$Services = [ordered]@{
16 migrate = @{ Exe = 'migrate'; Port = 0; Health = $false }
17 apihost = @{ Exe = 'apihost'; Port = 4081; Health = $true }
18 githost = @{ Exe = 'githost'; Port = 9418; Health = $true; Extra = @(4022) }
19 im = @{ Exe = 'im'; Port = 4082; Health = $true }
20 synchost = @{ Exe = 'synchost'; Port = 4083; Health = $true }
21 mails = @{ Exe = 'mails'; Port = 4084; Health = $true }
22 adminhost = @{ Exe = 'adminhost'; Port = 3004; Health = $true }
23 webhook = @{ Exe = 'webhook'; Port = 3005; Health = $true }
24 aihost = @{ Exe = 'aihost'; Port = 3006; Health = $true }
25 web = @{ Bun = 'web'; Port = 5173; Health = $false }
26 'web-admin' = @{ Bun = 'admin'; Port = 5174; Health = $false }
27}
28$Order = @('migrate', 'apihost', 'githost', 'im', 'synchost', 'mails', 'adminhost', 'webhook', 'aihost', 'web', 'web-admin')
29$Backends = @('migrate', 'apihost', 'githost', 'im', 'synchost', 'mails', 'adminhost', 'webhook', 'aihost')
30$Aliases = @{
31 api = 'apihost'
32 git = 'githost'
33 chat = 'im'
34 sync = 'synchost'
35 mail = 'mails'
36 hook = 'webhook'
37 ai = 'aihost'
38 admin = 'web-admin'
39 ui = 'web'
40}
41
42$Urls = [ordered]@{
43 apihost = 'http://127.0.0.1:4081 (Swagger: /api/docs)'
44 githost = 'http://127.0.0.1:9418 ssh://git@127.0.0.1:4022'
45 im = 'ws://127.0.0.1:4082'
46 synchost = 'http://127.0.0.1:4083/healthz'
47 mails = 'http://127.0.0.1:4084/healthz'
48 adminhost = 'http://127.0.0.1:3004'
49 webhook = 'http://127.0.0.1:3005'
50 aihost = 'http://127.0.0.1:3006/healthz'
51 web = 'http://127.0.0.1:5173'
52 'web-admin' = 'http://127.0.0.1:5174'
53}
54
55$Infra = @(
56 @{ Name = 'postgresql'; Port = 5432 },
57 @{ Name = 'redis8'; Port = 6379 },
58 @{ Name = 'rustfs'; Port = 9000 },
59 @{ Name = 'meilisearch'; Port = 7700 }
60)
61
62function Write-Info { param([string]$Message) Write-Host $Message }
63function Write-Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
64function Write-Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
65
66function Ensure-Dir {
67 foreach ($dir in @($DevDir, $LogDir, $PidDir)) {
68 if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
69 }
70}
71
72function Test-Port {
73 param([int]$Port, [int]$TimeoutMs = 1000)
74 if (-not $Port) { return $false }
75 $client = New-Object System.Net.Sockets.TcpClient
76 try {
77 $task = $client.ConnectAsync('127.0.0.1', $Port)
78 if (-not $task.Wait($TimeoutMs)) { return $false }
79 return $client.Connected
80 } catch {
81 return $false
82 } finally {
83 $client.Dispose()
84 }
85}
86
87function Test-Health {
88 param([string]$Name)
89 $svc = $Services[$Name]
90 if ($svc.Port -eq 0) { return $true }
91 if ($svc.Health) {
92 try {
93 $res = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 -Uri ("http://127.0.0.1:{0}/healthz" -f $svc.Port)
94 return ($res.StatusCode -eq 200)
95 } catch {
96 return $false
97 }
98 }
99 return (Test-Port $svc.Port)
100}
101
102function Get-PidPath { param([string]$Name) Join-Path $PidDir ("$Name.pid") }
103
104function Register-ServiceProcess {
105 param([string]$Name, [int]$ProcId)
106 Set-Content -LiteralPath (Get-PidPath $Name) -Value $ProcId -Encoding ascii
107}
108
109function Get-RunningPid {
110 param([string]$Name)
111 $file = Get-PidPath $Name
112 if (-not (Test-Path -LiteralPath $file)) { return 0 }
113 $procId = 0
114 if (-not [int]::TryParse((Get-Content -LiteralPath $file -First 1).Trim(), [ref]$procId)) { return 0 }
115 if (Get-Process -Id $procId -ErrorAction SilentlyContinue) { return $procId }
116 return 0
117}
118
119function Get-PortOwner {
120 param([int]$Port)
121 if (-not $Port) { return 0 }
122 $conn = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue | Select-Object -First 1
123 if ($conn) { return [int]$conn.OwningProcess }
124 return 0
125}
126
127function Test-OwnedByRepo {
128 param([int]$ProcId)
129 $proc = Get-Process -Id $ProcId -ErrorAction SilentlyContinue
130 if (-not $proc) { return $false }
131 $path = ''
132 try { $path = $proc.Path } catch { }
133 if ($path -and $path.StartsWith($Root, [StringComparison]::OrdinalIgnoreCase)) { return $true }
134 try {
135 $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$ProcId" -ErrorAction SilentlyContinue).CommandLine
136 if ($cmd -and $cmd.Contains($Root)) { return $true }
137 } catch { }
138 return $false
139}
140
141function Ensure-HostKey {
142 if (Test-Path -LiteralPath $DevHostKey) { return $DevHostKey }
143 Ensure-Dir
144 Write-Info "[hostkey] 生成本地 SSH host key: $DevHostKey"
145 & ssh-keygen -q -t ed25519 -f $DevHostKey -N '""' -C 'gitdata-dev' | Out-Null
146 if (-not (Test-Path -LiteralPath $DevHostKey)) { throw 'ssh-keygen 执行失败' }
147 return $DevHostKey
148}
149
150function Set-ServiceEnv {
151 $hostKey = Ensure-HostKey
152 $env:APP_GIT__SSH__HOST_KEY_PATH = $hostKey
153 foreach ($dir in @('C:\opt\gitdata\repos', 'C:\opt\gitdata\objects')) {
154 if (-not (Test-Path -LiteralPath $dir)) {
155 try { New-Item -ItemType Directory -Force -Path $dir | Out-Null } catch { }
156 }
157 }
158 if (-not (Test-Path -LiteralPath 'C:\opt\gitdata\repos')) {
159 Write-Warn '[warn] 无法创建 C:\opt\gitdata\repos,请在 config/local.toml 或 APP_GIT__REPO_ROOT 指定可写目录'
160 }
161}
162
163function Invoke-Migrate {
164 Ensure-Dir
165 Set-ServiceEnv
166 $exe = Join-Path $BinDir 'migrate.exe'
167 if (-not (Test-Path -LiteralPath $exe)) { throw '缺少 target\debug\migrate.exe,请先执行 just build' }
168 Write-Info '[migrate] 同步数据库 schema ...'
169 $proc = Start-Process -FilePath $exe -WorkingDirectory $Root -PassThru -Wait `
170 -RedirectStandardOutput (Join-Path $LogDir 'migrate.log') `
171 -RedirectStandardError (Join-Path $LogDir 'migrate.err.log') `
172 -WindowStyle Hidden
173 if ($proc.ExitCode -ne 0) { throw "migrate 失败 (exit=$($proc.ExitCode)),见 just logs migrate" }
174 Write-Info '[migrate] 完成'
175}
176
177function Start-Daemon {
178 param([string]$Name, [string]$Exe, [string]$Arguments, [string]$WorkingDirectory, [string]$Out, [string]$Err)
179 $launcher = Join-Path $PidDir "$Name.cmd"
180 $lines = @(
181 '@echo off'
182 'set "APP_GIT__SSH__HOST_KEY_PATH=' + $env:APP_GIT__SSH__HOST_KEY_PATH + '"'
183 'cd /d "' + $WorkingDirectory + '"'
184 '"' + $Exe + '" ' + $Arguments + ' 1> "' + $Out + '" 2> "' + $Err + '"'
185 )
186 Set-Content -LiteralPath $launcher -Value $lines -Encoding ascii
187
188 $cmd = 'cmd.exe /c "' + $launcher + '"'
189 $result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
190 CommandLine = $cmd
191 CurrentDirectory = $WorkingDirectory
192 }
193 if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed (return=$($result.ReturnValue))" }
194 return [int]$result.ProcessId
195}
196
197function Start-One {
198 param([string]$Name)
199 $svc = $Services[$Name]
200 $running = Get-RunningPid $Name
201 if ($running -and (Test-Health $Name)) {
202 Write-Info "[$Name] 已在运行 (pid $running)"
203 return $false
204 }
205 if ($svc.Port -and (Test-Port $svc.Port)) {
206 Write-Warn "[$Name] 端口 $($svc.Port) 已被 pid $(Get-PortOwner $svc.Port) 占用,跳过"
207 return $false
208 }
209
210 Ensure-Dir
211 Set-ServiceEnv
212 $out = Join-Path $LogDir "$Name.log"
213 $err = Join-Path $LogDir "$Name.err.log"
214
215 if ($svc.ContainsKey('Bun')) {
216 $exe = (Get-Command bun.exe -ErrorAction SilentlyContinue).Source
217 if (-not $exe) { throw '未找到 bun.exe,请先安装 bun 并加入 PATH' }
218 $dir = Join-Path $Root $svc.Bun
219 if (-not (Test-Path -LiteralPath $dir)) { throw "缺少目录 $dir" }
220 $procId = Start-Daemon -Name $Name -Exe $exe -Arguments 'run dev' -WorkingDirectory $dir -Out $out -Err $err
221 } else {
222 $exe = Join-Path $BinDir ($svc.Exe + '.exe')
223 if (-not (Test-Path -LiteralPath $exe)) { throw "缺少 $exe,请先执行 just build" }
224 $procId = Start-Daemon -Name $Name -Exe $exe -Arguments '' -WorkingDirectory $Root -Out $out -Err $err
225 }
226
227 Register-ServiceProcess $Name $procId
228 Write-Info "[$Name] 启动 pid=$procId"
229 return $true
230}
231
232function Wait-Ready {
233 param([string]$Name, [int]$TimeoutSec = 60)
234 $deadline = (Get-Date).AddSeconds($TimeoutSec)
235 while ((Get-Date) -lt $deadline) {
236 if (Test-Health $Name) { return $true }
237 if (-not (Get-RunningPid $Name)) { return $false }
238 Start-Sleep -Milliseconds 500
239 }
240 return $false
241}
242
243function Stop-Stray {
244 param([string]$Name, [int]$Port)
245 if (-not $Port) { return }
246 $owner = Get-PortOwner $Port
247 if (-not $owner) { return }
248 if (Test-OwnedByRepo $owner) {
249 & taskkill /PID $owner /T /F | Out-Null
250 Write-Info "[$Name] 清理残留监听 $Port (pid $owner)"
251 } else {
252 Write-Warn "[$Name] 端口 $Port 仍被 pid $owner 占用(非本仓库进程,未处理)"
253 }
254}
255
256function Stop-One {
257 param([string]$Name)
258 $svc = $Services[$Name]
259 $procId = Get-RunningPid $Name
260 if ($procId) {
261 & taskkill /PID $procId /T /F | Out-Null
262 Write-Info "[$Name] 已停止 (pid $procId)"
263 } else {
264 Write-Info "[$Name] 未在运行"
265 }
266 Remove-Item -LiteralPath (Get-PidPath $Name) -Force -ErrorAction SilentlyContinue
267 Remove-Item -LiteralPath (Join-Path $PidDir "$Name.cmd") -Force -ErrorAction SilentlyContinue
268 Stop-Stray $Name $svc.Port
269 foreach ($port in @($svc.Extra)) { if ($port) { Stop-Stray $Name $port } }
270}
271
272function Resolve-Services {
273 param([string[]]$Requested)
274 if (-not $Requested -or $Requested.Count -eq 0) { return $Order }
275 $names = New-Object System.Collections.Generic.List[string]
276 foreach ($item in $Requested) {
277 $raw = $item.Trim()
278 if (-not $raw) { continue }
279 foreach ($part in $raw.Split(',')) {
280 $key = $part.Trim().ToLower()
281 if (-not $key) { continue }
282 if ($Aliases.ContainsKey($key)) { $key = $Aliases[$key] }
283 if ($key -eq 'all') {
284 foreach ($n in $Order) { if (-not $names.Contains($n)) { $names.Add($n) } }
285 continue
286 }
287 if ($key -eq 'backends') {
288 foreach ($n in $Backends) { if (-not $names.Contains($n)) { $names.Add($n) } }
289 continue
290 }
291 if ($key -eq 'frontends') {
292 foreach ($n in @('web', 'web-admin')) { if (-not $names.Contains($n)) { $names.Add($n) } }
293 continue
294 }
295 if (-not $Services.Contains($key)) {
296 throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/mail/hook/ai/admin/ui/backends/frontends)"
297 }
298 if (-not $names.Contains($key)) { $names.Add($key) }
299 }
300 }
301 if ($names.Count -eq 0) { return $Order }
302 return $names.ToArray()
303}
304
305function Invoke-Check {
306 $ok = $true
307 Write-Info '== 工具链 =='
308 foreach ($tool in @('cargo', 'bun', 'ssh-keygen', 'wsl')) {
309 $found = Get-Command $tool -ErrorAction SilentlyContinue
310 if ($found) { Write-Info (" {0,-12} OK" -f $tool) } else { Write-Err (" {0,-12} 缺失" -f $tool); $ok = $false }
311 }
312 Write-Info '== WSL 基础设施 =='
313 foreach ($item in $Infra) {
314 if (Test-Port $item.Port) {
315 Write-Info (" {0,-12} 127.0.0.1:{1} OK" -f $item.Name, $item.Port)
316 } else {
317 Write-Err (" {0,-12} 127.0.0.1:{1} 不可用" -f $item.Name, $item.Port)
318 $ok = $false
319 }
320 }
321 Write-Info '== 后端二进制 =='
322 foreach ($name in $Backends) {
323 $exe = Join-Path $BinDir ($Services[$name].Exe + '.exe')
324 if (Test-Path -LiteralPath $exe) { Write-Info (" {0,-12} OK" -f $name) } else { Write-Warn (" {0,-12} 未构建(just build)" -f $name); $ok = $false }
325 }
326 if (-not $ok) {
327 Write-Info ''
328 Write-Info 'just infra 启动 WSL 侧 PostgreSQL / Redis / RustFS / Meilisearch'
329 Write-Info 'just build 构建全部后端二进制(前端由 build.rs 自动构建并内嵌)'
330 }
331}
332
333function Invoke-Infra {
334 param([switch]$Stop)
335 $verb = 'start'
336 if ($Stop) { $verb = 'stop' }
337 Write-Info "[infra] $verb WSL 服务: $($Infra.Name -join ', ')"
338 & wsl.exe -d Ubuntu -u root -- systemctl $verb @($Infra.Name)
339 if ($LASTEXITCODE -ne 0) { throw "wsl systemctl $verb 失败,请确认 WSL 发行版 Ubuntu 可用" }
340 Start-Sleep -Seconds 2
341 foreach ($item in $Infra) {
342 $state = 'DOWN'
343 if (Test-Port $item.Port) { $state = 'OK' }
344 Write-Info (" {0,-12} 127.0.0.1:{1} {2}" -f $item.Name, $item.Port, $state)
345 }
346}
347
348function Invoke-Up {
349 param([string[]]$Requested, [switch]$SkipMigrate)
350 $names = Resolve-Services $Requested
351 $needsBackend = $false
352 foreach ($n in $names) { if ($Backends -contains $n) { $needsBackend = $true } }
353
354 Set-ServiceEnv
355 foreach ($item in $Infra) {
356 if (-not (Test-Port $item.Port)) { Write-Warn "[warn] $($item.Name) 未监听 127.0.0.1:$($item.Port),可执行 just infra" }
357 }
358 if ($needsBackend -and -not $SkipMigrate) { Invoke-Migrate }
359
360 foreach ($name in $Order) {
361 if ($names -notcontains $name) { continue }
362 if ($name -eq 'migrate') { continue }
363 try {
364 $started = Start-One $name
365 } catch {
366 Write-Err "[$name] 启动失败: $($_.Exception.Message)"
367 continue
368 }
369 if ($started) {
370 if (Wait-Ready $name 60) { Write-Info "[$name] 就绪" } else { Write-Err "[$name] 60s 内未就绪,见 just logs $name" }
371 }
372 }
373
374 Write-Info ''
375 Write-Info '服务地址:'
376 foreach ($name in $Order) {
377 if ($names -notcontains $name) { continue }
378 if ($name -eq 'migrate') { continue }
379 Write-Info (" {0,-10} {1}" -f $name, $Urls[$name])
380 }
381}
382
383function Invoke-Down {
384 param([string[]]$Requested)
385 $names = Resolve-Services $Requested
386 $reversed = @($Order)
387 [array]::Reverse($reversed)
388 foreach ($name in $reversed) {
389 if ($names -notcontains $name) { continue }
390 Stop-One $name
391 }
392}
393
394function Invoke-Status {
395 Write-Info ("{0,-10} {1,-8} {2,-9} {3}" -f 'SERVICE', 'PID', 'PORT', 'STATE')
396 foreach ($name in $Order) {
397 if ($name -eq 'migrate') { continue }
398 $svc = $Services[$name]
399 $procId = Get-RunningPid $name
400 $portState = 'closed'
401 if (Test-Port $svc.Port) { $portState = 'listen' }
402 $state = 'stopped'
403 if ($procId) {
404 if (Test-Health $name) { $state = 'ready' } else { $state = 'starting/fail' }
405 }
406 Write-Info ("{0,-10} {1,-8} {2,-9} {3}" -f $name, $procId, $portState, $state)
407 }
408}
409
410function Invoke-Logs {
411 param([string[]]$Names, [switch]$Follow)
412 if (-not $Names -or $Names.Count -eq 0) { $Names = @('apihost') }
413 $target = $Names[0]
414 if (-not $Services.Contains($target)) { throw "未知服务: $target" }
415 foreach ($name in $Names) {
416 foreach ($suffix in @('log', 'err.log')) {
417 $path = Join-Path $LogDir "$name.$suffix"
418 if ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).Length -gt 0) {
419 Write-Info "===== $name.$suffix (tail 80) ====="
420 Get-Content -LiteralPath $path -Tail 80
421 }
422 }
423 }
424 if ($Follow) {
425 $path = Join-Path $LogDir "$target.log"
426 if (-not (Test-Path -LiteralPath $path)) { throw "没有日志文件: $path" }
427 Write-Info "===== follow $target.log (Ctrl+C 退出) ====="
428 Get-Content -LiteralPath $path -Tail 0 -Wait
429 }
430}
431
432function Get-ConfigUrl {
433 param([string]$Scheme)
434 $file = Join-Path $Root 'config\local.toml'
435 if (Test-Path -LiteralPath $file) {
436 $pattern = '^\s*url\s*=\s*"' + $Scheme + '://([^"]+)"'
437 foreach ($line in Get-Content -LiteralPath $file) {
438 if ($line -match $pattern) { return ($Scheme + '://' + $Matches[1]) }
439 }
440 }
441 return ''
442}
443
444function Invoke-Psql {
445 $url = Get-ConfigUrl 'postgres'
446 if (-not $url) { throw '未能从 config/local.toml 解析 postgres url' }
447 Write-Info "[psql] $url"
448 & wsl.exe -d Ubuntu -- psql $url
449}
450
451function Invoke-Redis {
452 $url = Get-ConfigUrl 'redis'
453 if (-not $url) { throw '未能从 config/local.toml 解析 redis url' }
454 Write-Info "[redis-cli] $url"
455 & wsl.exe -d Ubuntu -- redis-cli -u $url
456}
457
458try {
459 switch ($Command.ToLower()) {
460 'check' { Invoke-Check }
461 'infra' { Invoke-Infra }
462 'infra-down' { Invoke-Infra -Stop }
463 'hostkey' { Write-Info "[hostkey] $(Ensure-HostKey)" }
464 'migrate' { Invoke-Migrate }
465 'up' { Invoke-Up -Requested $Rest }
466 'down' { Invoke-Down -Requested $Rest }
467 'restart' { Invoke-Down -Requested $Rest; Invoke-Up -Requested $Rest -SkipMigrate }
468 'status' { Invoke-Status }
469 'psql' { Invoke-Psql }
470 'redis' { Invoke-Redis }
471 'logs' {
472 $follow = $false
473 $names = @()
474 foreach ($item in $Rest) {
475 if ($item -in @('-f', '--follow')) { $follow = $true } else { $names += $item }
476 }
477 Invoke-Logs -Names $names -Follow:$follow
478 }
479 default { throw "未知命令: $Command (check/infra/infra-down/hostkey/migrate/up/down/restart/status/logs/psql/redis)" }
480 }
481} catch {
482 Write-Err "ERROR: $($_.Exception.Message)"
483 exit 1
484}
Added scripts/dev.sh scripts/dev.sh +776 −0 View scripts/dev.sh
@@ -0,0 +1,776 @@
1#!/usr/bin/env bash
2# GitData 本地开发编排脚本(macOS / Linux)。
3# 由 justfile 调用:just <cmd> [svc...] -> scripts/dev.sh <cmd> [svc...]
4# 依赖服务(PostgreSQL / Redis / S3 / Meilisearch)由 `just infra` 通过 docker compose 托管。
5set -u
6
7ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8DEV_DIR="$ROOT/target/dev"
9LOG_DIR="$DEV_DIR/logs"
10PID_DIR="$DEV_DIR/pids"
11BIN_DIR="$ROOT/target/debug"
12DEV_HOST_KEY="$DEV_DIR/ssh_host_ed25519"
13COMPOSE_FILE="$ROOT/docker-compose.dev.yml"
14
15ORDER="apihost githost im synchost mails adminhost webhook aihost web web-admin"
16BACKENDS="apihost githost im synchost mails adminhost webhook aihost"
17INFRA="postgresql:5432 redis:6379 s3:9000 meilisearch:7700"
18
19if [ -t 1 ]; then
20 C_WARN="$(printf '\033[33m')"; C_ERR="$(printf '\033[31m')"; C_OFF="$(printf '\033[0m')"
21else
22 C_WARN=''; C_ERR=''; C_OFF=''
23fi
24
25info() { printf '%s\n' "$*"; }
26warn() { printf '%s%s%s\n' "$C_WARN" "$*" "$C_OFF"; }
27fail() { printf '%s%s%s\n' "$C_ERR" "$*" "$C_OFF" >&2; }
28
29contains() {
30 local needle="$1"; shift
31 local item
32 for item in $*; do
33 [ "$item" = "$needle" ] && return 0
34 done
35 return 1
36}
37
38port_of() {
39 case "$1" in
40 apihost) echo 4081 ;;
41 githost) echo 9418 ;;
42 im) echo 4082 ;;
43 synchost) echo 4083 ;;
44 mails) echo 4084 ;;
45 adminhost) echo 3004 ;;
46 webhook) echo 3005 ;;
47 aihost) echo 3006 ;;
48 web) echo 5173 ;;
49 web-admin) echo 5174 ;;
50 *) echo 0 ;;
51 esac
52}
53
54extra_port_of() {
55 case "$1" in
56 githost) echo 4022 ;;
57 *) echo 0 ;;
58 esac
59}
60
61alias_of() {
62 case "$1" in
63 api) echo apihost ;;
64 git) echo githost ;;
65 chat) echo im ;;
66 sync) echo synchost ;;
67 mail) echo mails ;;
68 hook) echo webhook ;;
69 ai) echo aihost ;;
70 admin) echo web-admin ;;
71 ui) echo web ;;
72 *) echo "$1" ;;
73 esac
74}
75
76url_of() {
77 case "$1" in
78 apihost) echo "http://127.0.0.1:4081 (Swagger: /api/docs)" ;;
79 githost) echo "http://127.0.0.1:9418 ssh://git@127.0.0.1:4022" ;;
80 im) echo "ws://127.0.0.1:4082" ;;
81 synchost) echo "http://127.0.0.1:4083/healthz" ;;
82 mails) echo "http://127.0.0.1:4084/healthz" ;;
83 adminhost) echo "http://127.0.0.1:3004" ;;
84 webhook) echo "http://127.0.0.1:3005" ;;
85 aihost) echo "http://127.0.0.1:3006/healthz" ;;
86 web) echo "http://127.0.0.1:5173" ;;
87 web-admin) echo "http://127.0.0.1:5174" ;;
88 esac
89}
90
91ensure_dirs() { mkdir -p "$DEV_DIR" "$LOG_DIR" "$PID_DIR"; }
92
93# macOS 默认没有 coreutils 的 timeout;有就直用,否则用后台轮询兜底。
94run_with_timeout() {
95 local secs="$1"; shift
96 if command -v timeout >/dev/null 2>&1; then
97 timeout "$secs" "$@"
98 return $?
99 fi
100 if command -v gtimeout >/dev/null 2>&1; then
101 gtimeout "$secs" "$@"
102 return $?
103 fi
104
105 "$@" &
106 local pid=$! elapsed=0
107 while [ "$elapsed" -lt "$((secs * 4))" ]; do
108 if ! kill -0 "$pid" 2>/dev/null; then
109 wait "$pid"
110 return $?
111 fi
112 sleep 0.25
113 elapsed=$((elapsed + 1))
114 done
115 kill -TERM "$pid" 2>/dev/null
116 sleep 1
117 kill -KILL "$pid" 2>/dev/null
118 wait "$pid" 2>/dev/null
119 return 124
120}
121
122# just 以非登录 shell 执行 recipe,常见工具链目录可能不在 PATH 里。
123# 这里把 macOS / Linux 的惯用位置补进来(仅在命令缺失时追加)。
124ensure_path() {
125 local candidates='' dir
126
127 case "$(uname -s)" in
128 Darwin)
129 candidates="$HOME/.cargo/bin $HOME/.bun/bin /opt/homebrew/bin /opt/homebrew/opt/rust/bin /usr/local/bin /usr/local/opt/rust/bin"
130 ;;
131 *)
132 candidates="$HOME/.cargo/bin $HOME/.bun/bin /usr/local/bin"
133 ;;
134 esac
135 # nvm 安装的 bun/node
136 for dir in "$HOME"/.nvm/versions/node/*/bin; do
137 [ -d "$dir" ] && candidates="$candidates $dir"
138 done
139 # volta / fnm / asdf 等常见版本管理器
140 for dir in "$HOME/.volta/bin" "$HOME/.local/share/fnm/aliases/default/bin" "$HOME/.asdf/shims"; do
141 [ -d "$dir" ] && candidates="$candidates $dir"
142 done
143
144 for dir in $candidates; do
145 [ -d "$dir" ] || continue
146 case ":$PATH:" in
147 *":$dir:"*) ;;
148 *) PATH="$PATH:$dir" ;;
149 esac
150 done
151 export PATH
152}
153
154pid_file() { echo "$PID_DIR/$1.pid"; }
155
156running_pid() {
157 local f p
158 f="$(pid_file "$1")"
159 [ -f "$f" ] || { echo 0; return; }
160 p="$(tr -d '[:space:]' < "$f")"
161 case "$p" in
162 ''|*[!0-9]*) echo 0 ;;
163 *) if kill -0 "$p" 2>/dev/null; then echo "$p"; else echo 0; fi ;;
164 esac
165}
166
167# 判断 127.0.0.1:port 是否在监听。
168# 优先查内核 socket 表(瞬时、可靠);连不上时再退回带超时的连接探测。
169# 注意:某些环境(WSL2 mirrored 网络、容器里的 iptables DROP)对未监听端口不回 RST,
170# 纯 /dev/tcp 连接会永久挂住,所以任何连接探测都必须带超时。
171port_open() {
172 local port="${1:-0}"
173 [ "$port" = "0" ] && return 1
174
175 if command -v ss >/dev/null 2>&1; then
176 if ss -ltn 2>/dev/null | awk -v p="$port" '{ n = split($4, a, ":"); if (a[n] == p) found = 1 } END { exit !found }'; then
177 return 0
178 fi
179 return 1
180 fi
181 if command -v lsof >/dev/null 2>&1; then
182 lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1 && return 0
183 return 1
184 fi
185 if command -v nc >/dev/null 2>&1; then
186 run_with_timeout 2 nc -z 127.0.0.1 "$port" >/dev/null 2>&1 && return 0
187 return 1
188 fi
189 run_with_timeout 2 bash -c "exec 3<>/dev/tcp/127.0.0.1/$port" >/dev/null 2>&1
190}
191
192port_owner() {
193 local port="$1"
194 if command -v lsof >/dev/null 2>&1; then
195 lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -n1
196 elif command -v ss >/dev/null 2>&1; then
197 ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p {print $NF}' | sed -E 's/.*pid=([0-9]+).*/\1/' | head -n1
198 fi
199}
200
201health_ok() {
202 local name="$1" port
203 port="$(port_of "$name")"
204 [ "$port" = "0" ] && return 0
205 case "$name" in
206 web|web-admin) port_open "$port" ;;
207 *)
208 if command -v curl >/dev/null 2>&1; then
209 # --noproxy '*':有些开发机设置了 http_proxy,否则 localhost 也会被代理
210 curl -fsS --noproxy '*' --max-time 3 "http://127.0.0.1:$port/healthz" >/dev/null 2>&1
211 else
212 port_open "$port"
213 fi
214 ;;
215 esac
216}
217
218config_url() {
219 local scheme="$1" found=''
220 if [ -f "$ROOT/config/local.toml" ]; then
221 found="$(grep -E "^[[:space:]]*url[[:space:]]*=[[:space:]]*\"${scheme}://" "$ROOT/config/local.toml" 2>/dev/null \
222 | head -n1 | sed -E 's/^[^"]*"([^"]+)".*$/\1/')"
223 fi
224 if [ -z "$found" ] && [ -f "$ROOT/.env" ]; then
225 found="$(grep -E "^[[:space:]]*(export[[:space:]]+)?[A-Z_]*URL[[:space:]]*=[[:space:]]*.${scheme}://" "$ROOT/.env" 2>/dev/null \
226 | head -n1 | sed -E 's/^[^=]*=[[:space:]]*//' | tr -d '"'"'"'')"
227 fi
228 printf '%s\n' "$found"
229}
230
231config_has_key() {
232 [ -f "$ROOT/config/local.toml" ] || return 1
233 grep -qE "^[[:space:]]*$1[[:space:]]*=" "$ROOT/config/local.toml" 2>/dev/null
234}
235
236# ---------- check ----------
237
238cmd_check() {
239 local ok=1 t pair name port
240 ensure_path
241 ensure_path
242
243 info '== 工具链 =='
244 for t in cargo bun ssh-keygen; do
245 if command -v "$t" >/dev/null 2>&1; then
246 printf ' %-12s OK\n' "$t"
247 else
248 fail " $(printf '%-12s 缺失' "$t")"; ok=0
249 fi
250 done
251 if command -v curl >/dev/null 2>&1; then
252 printf ' %-12s OK\n' curl
253 else
254 warn " $(printf '%-12s 缺失,健康检查将退化为端口探测' curl)"
255 fi
256 printf ' %-12s %s\n' 'os' "$(uname -s)"
257
258 info '== 依赖服务(PostgreSQL / Redis / S3 / Meilisearch)=='
259 for pair in $INFRA; do
260 name="${pair%%:*}"; port="${pair##*:}"
261 if port_open "$port"; then
262 printf ' %-12s 127.0.0.1:%s OK\n' "$name" "$port"
263 else
264 fail " $(printf '%-12s 127.0.0.1:%s 不可用' "$name" "$port")"; ok=0
265 fi
266 done
267
268 info '== 后端二进制 =='
269 for name in $BACKENDS; do
270 if [ -x "$BIN_DIR/$name" ]; then
271 printf ' %-12s OK\n' "$name"
272 else
273 warn " $(printf '%-12s 未构建' "$name")"; ok=0
274 fi
275 done
276
277 if [ "$ok" = "0" ]; then
278 info ''
279 info 'just infra 启动依赖服务(docker compose)'
280 info 'just build 构建全部后端二进制(前端由 build.rs 自动构建并内嵌)'
281 return 1
282 fi
283 return 0
284}
285
286# ---------- infra ----------
287
288infra_ports_ready() {
289 local pair port
290 for pair in $INFRA; do
291 port="${pair##*:}"
292 port_open "$port" || return 1
293 done
294 return 0
295}
296
297cmd_infra() {
298 local action="${1:-up}" pair name port rc=0
299
300 # 四个端口都已就绪时不再动 docker(例如本机已用 brew / systemd 装好了依赖服务)
301 if [ "$action" = "up" ] && infra_ports_ready; then
302 info '[infra] 依赖服务已在运行(端口均已监听),跳过 docker compose'
303 for pair in $INFRA; do
304 name="${pair%%:*}"; port="${pair##*:}"
305 printf ' %-12s 127.0.0.1:%s OK\n' "$name" "$port"
306 done
307 return 0
308 fi
309
310 if ! command -v docker >/dev/null 2>&1; then
311 fail ' 未找到 docker,无法托管本地依赖服务'
312 info ' 二选一:'
313 info ' macOS: brew install --cask docker (或 colima: brew install colima && colima start)'
314 info ' Linux: 安装 docker engine + compose plugin'
315 info ' 也可自行启动 PostgreSQL/Redis/S3/Meilisearch,或用 APP_* 环境变量指向已有实例'
316 return 1
317 fi
318 if ! docker compose version >/dev/null 2>&1; then
319 fail ' docker compose 不可用(需要 Compose v2)'
320 return 1
321 fi
322 if [ ! -f "$COMPOSE_FILE" ]; then
323 fail " 缺少 $COMPOSE_FILE"
324 return 1
325 fi
326
327 case "$action" in
328 up)
329 info "[infra] docker compose up -d ($COMPOSE_FILE)"
330 docker compose -f "$COMPOSE_FILE" up -d || rc=$?
331 ;;
332 down)
333 info "[infra] docker compose down ($COMPOSE_FILE)"
334 docker compose -f "$COMPOSE_FILE" down || rc=$?
335 ;;
336 esac
337 [ "$rc" != "0" ] && return "$rc"
338
339 sleep 2
340 for pair in $INFRA; do
341 name="${pair%%:*}"; port="${pair##*:}"
342 if port_open "$port"; then
343 printf ' %-12s 127.0.0.1:%s OK\n' "$name" "$port"
344 else
345 warn " $(printf '%-12s 127.0.0.1:%s 未就绪' "$name" "$port")"
346 fi
347 done
348 return 0
349}
350
351# ---------- host key / env ----------
352
353ensure_host_key() {
354 if [ -f "$DEV_HOST_KEY" ]; then
355 printf '%s\n' "$DEV_HOST_KEY"; return 0
356 fi
357 ensure_dirs
358 info "[hostkey] 生成本地 SSH host key: $DEV_HOST_KEY"
359 ssh-keygen -q -t ed25519 -f "$DEV_HOST_KEY" -N '' -C gitdata-dev >/dev/null 2>&1
360 if [ ! -f "$DEV_HOST_KEY" ]; then
361 fail ' ssh-keygen 执行失败'
362 return 1
363 fi
364 printf '%s\n' "$DEV_HOST_KEY"
365}
366
367service_env() {
368 ensure_path
369 ensure_dirs
370 if [ -z "${APP_GIT__SSH__HOST_KEY_PATH:-}" ]; then
371 APP_GIT__SSH__HOST_KEY_PATH="$(ensure_host_key)" || return 1
372 fi
373 if [ -z "${APP_GIT__REPO_ROOT:-}" ]; then
374 APP_GIT__REPO_ROOT="$DEV_DIR/git/repos"
375 fi
376 if [ -z "${APP_GIT__OBJECTS_ROOT:-}" ]; then
377 APP_GIT__OBJECTS_ROOT="$DEV_DIR/git/objects"
378 fi
379 export APP_GIT__SSH__HOST_KEY_PATH APP_GIT__REPO_ROOT APP_GIT__OBJECTS_ROOT
380 mkdir -p "$APP_GIT__REPO_ROOT" "$APP_GIT__OBJECTS_ROOT"
381
382 # macOS 的 HOME 通常不可写 /opt;若 local.toml 指向 /opt 又不可写则给出提示
383 if ! config_has_key 'repo_root'; then
384 return 0
385 fi
386}
387
388# ---------- migrate ----------
389
390cmd_migrate() {
391 ensure_path
392 service_env || return 1
393 if [ ! -x "$BIN_DIR/migrate" ]; then
394 fail ' 缺少 target/debug/migrate,请先执行 just build'
395 return 1
396 fi
397 info '[migrate] 同步数据库 schema ...'
398 if ! "$BIN_DIR/migrate" >"$LOG_DIR/migrate.log" 2>"$LOG_DIR/migrate.err.log"; then
399 fail ' [migrate] 失败,见 just logs migrate'
400 tail -n 15 "$LOG_DIR/migrate.err.log" 2>/dev/null
401 return 1
402 fi
403 info '[migrate] 完成'
404}
405
406# ---------- start / stop ----------
407
408spawn_daemon() {
409 local name="$1" dir="$2" cmd="$3"
410 ensure_path
411 ensure_path
412 local out="$LOG_DIR/$name.log" err="$LOG_DIR/$name.err.log"
413 local pid
414
415 cd "$dir" || return 1
416 if command -v setsid >/dev/null 2>&1; then
417 nohup setsid $cmd >"$out" 2>"$err" </dev/null &
418 else
419 # macOS 无 setsid;nohup + 重定向已足够脱离调用方的管道句柄
420 nohup $cmd >"$out" 2>"$err" </dev/null &
421 fi
422 pid=$!
423 printf '%s\n' "$pid" > "$(pid_file "$name")"
424 cd "$ROOT" || true
425 printf '%s\n' "$pid"
426}
427
428# 前端依赖自检:
429# 1) node_modules 缺失 -> bun install
430# 2) 原生绑定与当前平台不匹配(跨平台切换工作区常见)-> 重装
431# 3) bun 本身不是本平台可执行文件(例如 WSL 里装的是 bun.exe)-> 明确报错
432ensure_frontend_deps() {
433 local dir="$1" name="$2"
434 ensure_path
435
436 if ! command -v bun >/dev/null 2>&1; then
437 fail " 未找到 bun,无法启动 $name(macOS: brew install oven-sh/bun/bun)"
438 return 1
439 fi
440
441 # bun 必须是本平台原生可执行文件;WSL 里指向 bun.exe 时无法跑 Linux 原生模块
442 local bun_path bun_kind
443 bun_path="$(command -v bun)"
444 bun_kind="$(file -b "$bun_path" 2>/dev/null || echo unknown)"
445 case "$bun_kind" in
446 *PE32*|*MS-DOS*)
447 if [ "$(uname -s)" != "Darwin" ]; then
448 fail " bun 指向 Windows 可执行文件($bun_path)"
449 info " 该环境无法运行 Linux/macOS 原生前端依赖,请安装本平台原生 bun:"
450 info " curl -fsSL https://bun.sh/install | bash"
451 return 1
452 fi
453 ;;
454 esac
455
456 if [ ! -d "$dir/node_modules" ] || [ ! -e "$dir/node_modules/.bin/vite" ]; then
457 info "[$name] 安装前端依赖 ..."
458 (cd "$dir" && bun install) || { fail " [$name] bun install 失败"; return 1; }
459 return 0
460 fi
461
462 # 冒烟测试本地 vite:原生绑定不匹配时会直接失败
463 if (cd "$dir" && run_with_timeout 60 ./node_modules/.bin/vite --version >/dev/null 2>&1); then
464 return 0
465 fi
466
467 warn "[$name] 本地 vite 无法运行(原生绑定与当前平台不匹配),重新安装依赖 ..."
468 (cd "$dir" && bun install) || { fail " [$name] bun install 失败"; return 1; }
469 if (cd "$dir" && run_with_timeout 60 ./node_modules/.bin/vite --version >/dev/null 2>&1); then
470 return 0
471 fi
472 fail " [$name] 依赖仍然不可用,见 just logs $name"
473 return 1
474}
475
476start_one() {
477 local name="$1" port pid dir cmd
478 port="$(port_of "$name")"
479 pid="$(running_pid "$name")"
480
481 if [ "$pid" != "0" ] && health_ok "$name"; then
482 info "[$name] 已在运行 (pid $pid)"
483 return 1
484 fi
485 if port_open "$port"; then
486 warn "[$name] 端口 $port 已被 pid $(port_owner "$port" | tr -d '[:space:]') 占用,跳过"
487 return 1
488 fi
489
490 ensure_dirs
491 service_env || return 1
492
493 case "$name" in
494 web|web-admin)
495 if [ "$name" = "web" ]; then dir="$ROOT/web"; else dir="$ROOT/admin"; fi
496 [ -d "$dir" ] || { fail " 缺少目录 $dir"; return 1; }
497 ensure_frontend_deps "$dir" "$name" || return 1
498 # --strictPort:端口被占用时直接失败,避免 vite 悄悄换到别的端口
499 cmd="bun run dev -- --strictPort --port $(port_of "$name")"
500 ;;
501 *)
502 [ -x "$BIN_DIR/$name" ] || { fail " 缺少 $BIN_DIR/$name,请先执行 just build"; return 1; }
503 dir="$ROOT"
504 cmd="$BIN_DIR/$name"
505 ;;
506 esac
507
508 pid="$(spawn_daemon "$name" "$dir" "$cmd")" || { fail " $name 启动失败"; return 1; }
509 info "[$name] 启动 pid=$pid"
510 return 0
511}
512
513wait_ready() {
514 local name="$1" timeout="${2:-90}" waited=0
515 while [ "$waited" -lt "$timeout" ]; do
516 if health_ok "$name"; then return 0; fi
517 if [ "$(running_pid "$name")" = "0" ]; then return 1; fi
518 sleep 1; waited=$((waited + 1))
519 done
520 return 1
521}
522
523kill_tree() {
524 local pid="$1"
525 [ -z "$pid" ] || [ "$pid" = "0" ] && return 0
526 if command -v pkill >/dev/null 2>&1; then
527 pkill -TERM -P "$pid" 2>/dev/null
528 fi
529 kill -TERM "$pid" 2>/dev/null
530 local i=0
531 while [ "$i" -lt 10 ]; do
532 kill -0 "$pid" 2>/dev/null || return 0
533 sleep 1; i=$((i + 1))
534 done
535 if command -v pkill >/dev/null 2>&1; then
536 pkill -KILL -P "$pid" 2>/dev/null
537 fi
538 kill -KILL "$pid" 2>/dev/null
539 return 0
540}
541
542stop_stray() {
543 local name="$1" port="${2:-0}" owner exe
544 [ "$port" = "0" ] && return 0
545 owner="$(port_owner "$port" | tr -d '[:space:]')"
546 [ -z "$owner" ] && return 0
547 # Linux:/proc/PID/exe 可解析真实可执行文件;macOS 无 /proc,退化为命令行匹配
548 if [ -h "/proc/$owner/exe" ]; then
549 exe="$(readlink "/proc/$owner/exe" 2>/dev/null)"
550 case "$exe" in
551 "$BIN_DIR"/*) kill_tree "$owner"; info "[$name] 清理残留监听 $port (pid $owner)"; return 0 ;;
552 esac
553 fi
554 case "$(ps -o command= -p "$owner" 2>/dev/null)" in
555 *"$ROOT"*) kill_tree "$owner"; info "[$name] 清理残留监听 $port (pid $owner)" ;;
556 *) warn "[$name] 端口 $port 仍被 pid $owner 占用(非本仓库进程,未处理)" ;;
557 esac
558 return 0
559}
560
561stop_one() {
562 local name="$1" port pid
563 port="$(port_of "$name")"
564 pid="$(running_pid "$name")"
565 if [ "$pid" != "0" ]; then
566 kill_tree "$pid"
567 info "[$name] 已停止 (pid $pid)"
568 else
569 info "[$name] 未在运行"
570 fi
571 rm -f "$(pid_file "$name")"
572 stop_stray "$name" "$port"
573 stop_stray "$name" "$(extra_port_of "$name")"
574 return 0
575}
576
577# ---------- service selection ----------
578
579resolve_services() {
580 local raw part key seen out=''
581 if [ "$#" -eq 0 ]; then
582 printf '%s\n' $ORDER
583 return 0
584 fi
585 for raw in "$@"; do
586 # 支持逗号分隔与空格分隔
587 for part in $(printf '%s' "$raw" | tr ',' ' '); do
588 key="$(printf '%s' "$part" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
589 [ -z "$key" ] && continue
590 key="$(alias_of "$key")"
591 case "$key" in
592 all) out="$out $ORDER" ;;
593 backends) out="$out $BACKENDS" ;;
594 frontends) out="$out web web-admin" ;;
595 *)
596 if ! contains "$key" $ORDER; then
597 fail "未知服务: $part"
598 info "可用: $ORDER"
599 info "别名: api git chat sync mail hook ai admin ui / backends / frontends"
600 return 1
601 fi
602 out="$out $key"
603 ;;
604 esac
605 done
606 done
607 # 去重且按 ORDER 排序
608 for key in $ORDER; do
609 contains "$key" $out && printf '%s\n' "$key"
610 done
611 return 0
612}
613
614# ---------- up / down / status / logs ----------
615
616cmd_up() {
617 local names t
618 names="$(resolve_services "$@")" || return 1
619 [ -z "$names" ] && return 0
620
621 local needs_backend=0 name
622 for name in $names; do
623 contains "$name" "$BACKENDS" && needs_backend=1
624 done
625
626 service_env >/dev/null 2>&1
627
628 local pair pname pport
629 for pair in $INFRA; do
630 pname="${pair%%:*}"; pport="${pair##*:}"
631 port_open "$pport" || warn "[warn] $pname 未监听 127.0.0.1:$pport,可执行 just infra"
632 done
633
634 if [ "$needs_backend" = "1" ] && [ "${SKIP_MIGRATE:-0}" != "1" ]; then
635 cmd_migrate || return 1
636 fi
637
638 for name in $names; do
639 if start_one "$name"; then
640 if wait_ready "$name" 90; then
641 info "[$name] 就绪"
642 else
643 fail "[$name] 90s 内未就绪,见 just logs $name"
644 fi
645 fi
646 done
647
648 info ''
649 info '服务地址:'
650 for name in $names; do
651 printf ' %-10s %s\n' "$name" "$(url_of "$name")"
652 done
653 return 0
654}
655
656cmd_down() {
657 local names t
658 names="$(resolve_services "$@")" || return 1
659 # 逆序停止
660 local reversed='' name
661 for name in $ORDER; do
662 contains "$name" "$names" && reversed="$name $reversed"
663 done
664 for name in $reversed; do
665 stop_one "$name"
666 done
667 return 0
668}
669
670cmd_status() {
671 local name pid port portstate state
672 printf '%-10s %-8s %-9s %s\n' SERVICE PID PORT STATE
673 for name in $ORDER; do
674 pid="$(running_pid "$name")"
675 port="$(port_of "$name")"
676 if port_open "$port"; then portstate=listen; else portstate=closed; fi
677 if [ "$pid" = "0" ]; then
678 state=stopped
679 elif health_ok "$name"; then
680 state=ready
681 else
682 state=starting/fail
683 fi
684 printf '%-10s %-8s %-9s %s\n' "$name" "$pid" "$portstate" "$state"
685 done
686 return 0
687}
688
689cmd_logs() {
690 local follow=0 names='' raw name suffix path
691 for raw in "$@"; do
692 case "$raw" in
693 -f|--follow) follow=1 ;;
694 *) names="$names $raw" ;;
695 esac
696 done
697 [ -z "$(printf '%s' "$names" | tr -d '[:space:]')" ] && names="apihost"
698 names="$(printf '%s' "$names" | tr ',' ' ')"
699
700 for name in $names; do
701 if ! contains "$name" $ORDER; then
702 fail "未知服务: $name"
703 return 1
704 fi
705 for suffix in log err.log; do
706 path="$LOG_DIR/$name.$suffix"
707 [ -s "$path" ] || continue
708 info "===== $name.$suffix (tail 80) ====="
709 tail -n 80 "$path"
710 done
711 done
712
713 if [ "$follow" = "1" ]; then
714 name="$(printf '%s' "$names" | awk '{print $1}')"
715 path="$LOG_DIR/$name.log"
716 if [ ! -f "$path" ]; then
717 fail "没有日志文件: $path"
718 return 1
719 fi
720 info "===== follow $name.log (Ctrl+C 退出) ====="
721 tail -n 0 -f "$path"
722 fi
723 return 0
724}
725
726# ---------- db / redis ----------
727
728cmd_psql() {
729 local url
730 url="${APP_DATABASE__URL:-$(config_url postgres)}"
731 [ -z "$url" ] && url="postgres://postgres:postgres@127.0.0.1:5432/gitdata"
732 info "[psql] $url"
733 if ! command -v psql >/dev/null 2>&1; then
734 fail ' 未找到 psql 客户端'
735 info ' macOS: brew install postgresql@16'
736 return 1
737 fi
738 psql "$url"
739}
740
741cmd_redis() {
742 local url
743 url="${APP_REDIS__URL:-$(config_url redis)}"
744 [ -z "$url" ] && url="redis://127.0.0.1:6379"
745 info "[redis-cli] $url"
746 if ! command -v redis-cli >/dev/null 2>&1; then
747 fail ' 未找到 redis-cli'
748 info ' macOS: brew install redis'
749 return 1
750 fi
751 redis-cli -u "$url"
752}
753
754cmd_hostkey() { info "[hostkey] $(ensure_host_key)"; }
755
756# ---------- dispatch ----------
757
758case "${1:-status}" in
759 check) cmd_check ;;
760 infra) cmd_infra up ;;
761 infra-down) cmd_infra down ;;
762 hostkey) cmd_hostkey ;;
763 migrate) cmd_migrate ;;
764 up) shift; cmd_up "$@" ;;
765 down) shift; cmd_down "$@" ;;
766 restart) shift; cmd_down "$@"; SKIP_MIGRATE=1 cmd_up "$@" ;;
767 status) cmd_status ;;
768 logs) shift; cmd_logs "$@" ;;
769 psql) cmd_psql ;;
770 redis) cmd_redis ;;
771 *)
772 fail "未知命令: ${1:-}"
773 info '可用: check/infra/infra-down/hostkey/migrate/up/down/restart/status/logs/psql/redis'
774 exit 1
775 ;;
776esac
Added docker-compose.dev.yml docker-compose.dev.yml +100 −0 View docker-compose.dev.yml
@@ -0,0 +1,100 @@
1# 本地开发依赖服务(macOS / Linux)。
2# 与 config/local.toml 的默认端口一一对应:
3# PostgreSQL 5432 / Redis 6379 / S3(RustFS) 9000 / Meilisearch 7700
4# 用法:
5# just infra 启动
6# just infra-down 停止
7# 仅用于本地开发,账号密码均为固定值,请勿用于任何对外环境。
8name: gitdata-dev
9
10services:
11 postgres:
12 image: postgres:16-alpine
13 container_name: gitdata-dev-postgres
14 restart: unless-stopped
15 environment:
16 POSTGRES_USER: postgres
17 POSTGRES_PASSWORD: postgres
18 POSTGRES_DB: gitdata
19 ports:
20 - "127.0.0.1:5432:5432"
21 volumes:
22 - postgres-data:/var/lib/postgresql/data
23 healthcheck:
24 test: ["CMD-SHELL", "pg_isready -U postgres -d gitdata"]
25 interval: 5s
26 timeout: 3s
27 retries: 20
28
29 redis:
30 image: redis:7-alpine
31 container_name: gitdata-dev-redis
32 restart: unless-stopped
33 command: ["redis-server", "--appendonly", "no", "--save", ""]
34 ports:
35 - "127.0.0.1:6379:6379"
36 healthcheck:
37 test: ["CMD", "redis-cli", "ping"]
38 interval: 5s
39 timeout: 3s
40 retries: 20
41
42 # S3 兼容对象存储:RustFS
43 rustfs:
44 image: rustfs/rustfs:latest
45 container_name: gitdata-dev-rustfs
46 restart: unless-stopped
47 environment:
48 RUSTFS_ADDRESS: ":9000"
49 RUSTFS_CONSOLE_ENABLE: "true"
50 RUSTFS_CONSOLE_ADDRESS: ":9001"
51 RUSTFS_ACCESS_KEY: rustfsadmin
52 RUSTFS_SECRET_KEY: rustfsadmin
53 RUSTFS_VOLUMES: /data
54 ports:
55 - "127.0.0.1:9000:9000"
56 - "127.0.0.1:9001:9001"
57 volumes:
58 - rustfs-data:/data
59 healthcheck:
60 test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/ >/dev/null || exit 1"]
61 interval: 5s
62 timeout: 3s
63 retries: 20
64
65 meilisearch:
66 image: getmeili/meilisearch:v1.13
67 container_name: gitdata-dev-meilisearch
68 restart: unless-stopped
69 environment:
70 MEILI_MASTER_KEY: 48e6813b44ed0de5d7272e06967703fb
71 MEILI_ENV: development
72 MEILI_NO_ANALYTICS: "true"
73 ports:
74 - "127.0.0.1:7700:7700"
75 volumes:
76 - meili-data:/meili_data
77 healthcheck:
78 test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:7700/health >/dev/null || exit 1"]
79 interval: 5s
80 timeout: 3s
81 retries: 20
82
83 # 本地 S3 bucket 初始化(建出 config/local.toml 里声明的 bucket)
84 minio-init:
85 image: minio/mc:latest
86 container_name: gitdata-dev-bucket-init
87 depends_on:
88 rustfs:
89 condition: service_started
90 entrypoint: ["/bin/sh", "-c"]
91 command:
92 - |
93 until mc alias set local http://rustfs:9000 rustfsadmin rustfsadmin >/dev/null 2>&1; do sleep 1; done
94 mc mb --ignore-existing local/gitdata
95 echo "bucket ready: gitdata"
96
97volumes:
98 postgres-data:
99 rustfs-data:
100 meili-data:
Added justfile justfile +138 −0 View justfile
@@ -0,0 +1,138 @@
1# GitData 本地开发入口(Windows / macOS / Linux)。
2#
3# Windows:直接在 Windows 上跑 Rust/前端,依赖服务(PostgreSQL / Redis / RustFS /
4# Meilisearch)由 WSL(Ubuntu) 的 systemd 托管(just infra)。
5# macOS/Linux:原生跑 Rust/前端,依赖服务由 docker compose 托管(just infra)。
6#
7# 配置沿用 config/local.toml(gitignore)。首次使用:
8# just check -> just infra -> just build -> just up
9# 常用:just up / just down / just status / just tail apihost
10#
11# 依赖工具:cargo、bun、ssh-keygen;Windows 额外需要 WSL 的 Ubuntu 发行版,
12# macOS/Linux 额外需要 docker + docker compose。
13# 需要 just >= 1.21。
14
15set shell := ["bash", "-cu"]
16set windows-shell := ["powershell.exe", "-NoLogo", "-NoProfile", "-Command"]
17
18root := justfile_directory()
19dev := if os() == "windows" {
20 root + "\\scripts\\dev.ps1"
21} else {
22 root + "/scripts/dev.sh"
23}
24# PowerShell 调用脚本需要 & 前缀;POSIX shell 直接执行
25invoke := if os() == "windows" { "& " } else { "" }
26
27# 查看服务状态(默认)
28status:
29 @{{ invoke }}"{{ dev }}" status
30
31# 自检:工具链 / 依赖服务 / 后端二进制
32check:
33 @{{ invoke }}"{{ dev }}" check
34
35# 启动依赖服务:PostgreSQL / Redis / RustFS(S3) / Meilisearch
36infra:
37 @{{ invoke }}"{{ dev }}" infra
38
39# 停止依赖服务
40infra-down:
41 @{{ invoke }}"{{ dev }}" infra-down
42
43# 生成 githost 使用的本地 SSH host key(target/dev/ssh_host_ed25519)
44hostkey:
45 @{{ invoke }}"{{ dev }}" hostkey
46
47# 同步数据库 schema
48migrate:
49 @{{ invoke }}"{{ dev }}" migrate
50
51# 构建全部后端二进制(web/admin 前端由 build.rs 构建并内嵌)
52build:
53 @cargo build --bins
54
55# 构建全部后端 release 二进制
56build-release:
57 @cargo build --release --bins
58
59# 启动全部服务(后端 + web + admin)
60up *svc='':
61 @{{ invoke }}"{{ dev }}" up {{ svc }}
62
63# 仅启动后端服务(apihost/githost/im/synchost/mails/adminhost/webhook/aihost)
64up-backend:
65 @{{ invoke }}"{{ dev }}" up backends
66
67# 仅启动前端 web(5173) 与 admin(5174)
68up-frontend:
69 @{{ invoke }}"{{ dev }}" up frontends
70
71# 停止全部服务,或指定:just down apihost im
72down *svc='':
73 @{{ invoke }}"{{ dev }}" down {{ svc }}
74
75# 重启全部服务,或指定:just restart apihost
76restart *svc='':
77 @{{ invoke }}"{{ dev }}" restart {{ svc }}
78
79# 最近日志:just logs / just logs apihost / just logs "apihost im"
80logs *svc="apihost":
81 @{{ invoke }}"{{ dev }}" logs {{ svc }}
82
83# 跟踪日志:just tail / just tail apihost
84tail svc="apihost":
85 @{{ invoke }}"{{ dev }}" logs {{ svc }} -f
86
87# 进入 PostgreSQL 交互终端
88psql:
89 @{{ invoke }}"{{ dev }}" psql
90
91# 进入 redis-cli 交互终端
92redis:
93 @{{ invoke }}"{{ dev }}" redis
94
95# URL 速查
96urls:
97 @echo " apihost http://127.0.0.1:4081 (Swagger: /api/docs)"
98 @echo " githost http://127.0.0.1:9418 ssh://git@127.0.0.1:4022"
99 @echo " im ws://127.0.0.1:4082"
100 @echo " synchost http://127.0.0.1:4083/healthz"
101 @echo " mails http://127.0.0.1:4084/healthz"
102 @echo " adminhost http://127.0.0.1:3004"
103 @echo " webhook http://127.0.0.1:3005"
104 @echo " aihost http://127.0.0.1:3006/healthz"
105 @echo " web http://127.0.0.1:5173"
106 @echo " web-admin http://127.0.0.1:5174"
107
108# 重新生成 openapi.json 与 admin/openapi.json
109openapi:
110 @cargo run -p openapi
111
112# 校验 openapi 是否与源码一致
113openapi-check:
114 @cargo run -p openapi -- --check
115
116# web 前端单独构建(cargo 构建时会自动执行)
117web-build:
118 @bun run --cwd web build
119
120# admin 前端单独构建
121admin-build:
122 @bun run --cwd admin build
123
124# web 前端 lint(oxlint + FSD 导航守卫)
125web-lint:
126 @bun run --cwd web lint
127
128# cargo fmt --all
129fmt:
130 @cargo fmt --all
131
132# cargo clippy 全 workspace
133clippy:
134 @cargo clippy --workspace --all-targets --all-features
135
136# 清理构建产物
137clean:
138 @cargo clean
No newline at end of file