You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

379 lines
12 KiB

# email-forecast one-click start
# Stop old services / free ports, then start stack.
#
# Default: local (mysql container + host pnpm dev/worker)
# - avoids rebuilding web/worker images (Docker Hub often blocked in CN)
# Compose full stack:
# .\docs\start-system.ps1 -Mode compose
#
# NOTE: Keep this file ASCII-only (Windows PowerShell 5.1 + UTF-8 without BOM).
[CmdletBinding()]
param(
[ValidateSet("compose", "local")]
[string]$Mode = "local",
[switch]$NoBuild,
[switch]$NoFallback,
[int]$WebPort = 3100,
[int]$MysqlPort = 7023,
[int]$HealthTimeoutSec = 120
)
$ErrorActionPreference = "Stop"
function Get-RepoRoot {
$here = $PSScriptRoot
if (-not $here) {
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
}
$root = Resolve-Path (Join-Path $here "..")
if (-not (Test-Path (Join-Path $root "package.json"))) {
throw "Repo root not found (missing package.json). Keep this script under docs/."
}
return $root.Path
}
function Write-Step([string]$msg) {
Write-Host ""
Write-Host "==> $msg" -ForegroundColor Cyan
}
function Stop-PortListeners([int]$Port) {
try {
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
} catch {
$conns = $null
}
if (-not $conns) {
Write-Host " port $Port is free"
return
}
$pids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
foreach ($procId in $pids) {
if (-not $procId -or $procId -eq 0) { continue }
try {
$p = Get-Process -Id $procId -ErrorAction SilentlyContinue
$name = if ($p) { $p.ProcessName } else { "?" }
Write-Host " kill PID=$procId ($name) on port $Port"
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
} catch {
Write-Host " cannot kill PID=$procId : $($_.Exception.Message)" -ForegroundColor Yellow
}
}
Start-Sleep -Seconds 1
}
function Stop-NodeDevProcesses {
$markers = @(
"next dev",
"next start",
"src/worker/index.ts",
"src\worker\index.ts",
"email-forecast"
)
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$cmd = $_.CommandLine
if (-not $cmd) { return $false }
if ($cmd -notmatch "node|tsx|pnpm") { return $false }
foreach ($m in $markers) {
if ($cmd -like "*$m*") { return $true }
}
return $false
} |
ForEach-Object {
Write-Host " kill node-related PID=$($_.ProcessId)"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
function Ensure-DotEnv([string]$RepoRoot) {
$envFile = Join-Path $RepoRoot ".env"
$example = Join-Path $RepoRoot ".env.example"
if (-not (Test-Path $envFile)) {
if (Test-Path $example) {
Copy-Item $example $envFile
Write-Host " created .env from .env.example"
} else {
Write-Host " WARN: missing .env" -ForegroundColor Yellow
}
}
}
function Wait-HttpOk([string]$Url, [int]$TimeoutSec) {
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
return $true
}
} catch {
Start-Sleep -Seconds 2
}
}
return $false
}
function Wait-MysqlReady([int]$MysqlPort, [int]$TimeoutSec = 120) {
Write-Step "wait MySQL healthy on port $MysqlPort"
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$portReady = $false
$healthReady = $false
while ((Get-Date) -lt $deadline) {
if (-not $portReady) {
$listening = Get-NetTCPConnection -LocalPort $MysqlPort -State Listen -ErrorAction SilentlyContinue
if ($listening) {
$portReady = $true
Write-Host " port $MysqlPort listening"
}
}
# Prefer compose healthcheck (accepts connections + auth), not just TCP
$status = ""
try {
$cid = (& docker compose ps -q mysql 2>$null | Select-Object -First 1)
if ($cid) {
$status = (& docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" $cid 2>$null)
if ($status) { $status = $status.Trim() }
}
} catch {
$status = ""
}
if ($status -eq "healthy" -or $status -eq "running") {
# "running" without health still needs ping; healthy is enough
if ($status -eq "healthy") {
$healthReady = $true
Write-Host " mysql health=healthy"
break
}
}
# Fallback: mysqladmin inside container (stderr warning must not stop script)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$null = & docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -uapp -papp --silent 2>$null
$pingOk = ($LASTEXITCODE -eq 0)
$ErrorActionPreference = $prevEap
if ($pingOk) {
$healthReady = $true
Write-Host " mysqladmin ping ok"
break
}
Start-Sleep -Seconds 2
}
if (-not $portReady) {
throw "MySQL port $MysqlPort not listening"
}
if (-not $healthReady) {
throw "MySQL not healthy within ${TimeoutSec}s (port up but server not ready — prisma P1017)"
}
# Brief settle: fresh container may still drop first connections
Start-Sleep -Seconds 2
}
function Invoke-PrismaDbPushRetry([int]$MaxAttempts = 8) {
Write-Step "prisma db push + seed"
$ok = $false
for ($i = 1; $i -le $MaxAttempts; $i++) {
pnpm exec prisma db push --skip-generate
if ($LASTEXITCODE -eq 0) {
$ok = $true
break
}
Write-Host " prisma db push attempt $i/$MaxAttempts failed (exit=$LASTEXITCODE), retry in 3s..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
}
if (-not $ok) { throw "prisma db push failed after $MaxAttempts attempts" }
pnpm db:seed
if ($LASTEXITCODE -ne 0) { throw "db:seed failed" }
}
function Start-LocalStack {
param(
[string]$RepoRoot,
[int]$WebPort,
[int]$MysqlPort,
[int]$HealthTimeoutSec
)
Write-Step "start mysql container only (no web/worker image build)"
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& docker compose up -d mysql 2>&1 | ForEach-Object { Write-Host $_ }
$upExit = $LASTEXITCODE
$ErrorActionPreference = $prevEap
if ($upExit -ne 0) {
throw @"
mysql start failed (exit=$upExit).
If pull mysql:8.0 also fails, Docker Hub is unreachable.
Fix: configure registry mirror in Docker Desktop, or use a VPN, then:
docker pull mysql:8.0
"@
}
Wait-MysqlReady -MysqlPort $MysqlPort -TimeoutSec 120
$env:DATABASE_URL = "mysql://app:app@localhost:$MysqlPort/email_forecast"
Invoke-PrismaDbPushRetry -MaxAttempts 8
$logDir = Join-Path $RepoRoot "data\logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
# Start-Process forbids the same path for stdout and stderr
$webOut = Join-Path $logDir "web-dev.out.log"
$webErr = Join-Path $logDir "web-dev.err.log"
$workerOut = Join-Path $logDir "worker.out.log"
$workerErr = Join-Path $logDir "worker.err.log"
Write-Step "start pnpm dev:web / pnpm worker (host Node, no Docker Hub)"
foreach ($f in @($webOut, $webErr, $workerOut, $workerErr)) {
if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue }
}
# Turbopack cache mixed with webpack causes /_next/_next/static/chunks 404 + ChunkLoadError
$nextDir = Join-Path $RepoRoot ".next"
$turboMarker = Join-Path $nextDir "static\chunks"
if ((Test-Path $nextDir) -and (Get-ChildItem $turboMarker -Filter "turbopack-*" -ErrorAction SilentlyContinue)) {
Remove-Item $nextDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Host " cleared stale Turbopack .next cache"
}
# Prefer cmd.exe wrapper: pnpm is often a .CMD shim; Start-Process + redirect is flaky on shims
$devArgs = "/c pnpm run dev:web > `"$webOut`" 2> `"$webErr`""
$workerArgs = "/c pnpm worker > `"$workerOut`" 2> `"$workerErr`""
Start-Process -FilePath "cmd.exe" -ArgumentList $devArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden
Start-Process -FilePath "cmd.exe" -ArgumentList $workerArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden
Write-Step "wait health http://127.0.0.1:$WebPort/api/health"
$ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec
if (-not $ok) {
Write-Host "health check timeout. logs:" -ForegroundColor Yellow
Write-Host " $webOut / $webErr"
Write-Host " $workerOut / $workerErr"
exit 2
}
# Dev 按需编译:登录后再预热运营页,否则 /mails 会落到登录页、切模块仍要编译 10s
Write-Step "warmup Next.js routes (first compile)"
$webSession = $null
$adminUser = if ($env:APP_ADMIN_USER) { $env:APP_ADMIN_USER } else { "admin" }
$adminPass = if ($env:APP_ADMIN_PASS) { $env:APP_ADMIN_PASS } else { "admin123" }
try {
$loginJson = (@{ username = $adminUser; password = $adminPass } | ConvertTo-Json -Compress)
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort/api/auth/login" -Method POST -Body $loginJson -ContentType "application/json" -UseBasicParsing -TimeoutSec 60 -SessionVariable webSession
Write-Host " warmed login session"
} catch {
Write-Host " login warmup skipped (pages still compile on first click)"
}
$warmupPaths = @(
"/login",
"/mails",
"/logs",
"/settings",
"/api/mails",
"/api/imports",
"/api/settings/mailboxes",
"/api/settings/oauth",
"/api/settings/cc",
"/api/audits"
)
foreach ($p in $warmupPaths) {
try {
if ($webSession) {
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180 -WebSession $webSession
} else {
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180
}
Write-Host " warmed $p"
} catch {
Write-Host " warmup $p done (status may be non-200)"
}
}
Write-Host " web log: $webOut / $webErr"
Write-Host " worker log: $workerOut / $workerErr"
}
function Start-ComposeStack {
param(
[switch]$NoBuild,
[int]$WebPort,
[int]$HealthTimeoutSec
)
Write-Step "start compose (mysql + web + worker images)"
if ($NoBuild) {
docker compose up -d
} else {
docker compose up --build -d
}
if ($LASTEXITCODE -ne 0) {
throw "docker compose up failed (exit=$LASTEXITCODE)"
}
Write-Step "wait health http://127.0.0.1:$WebPort/api/health"
$ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec
docker compose ps
if (-not $ok) {
Write-Host "health check timeout. run: pnpm compose:logs" -ForegroundColor Yellow
exit 2
}
}
# ---------- main ----------
$Root = Get-RepoRoot
Set-Location $Root
Write-Host "repo: $Root"
Write-Host "mode: $Mode (default local = no node image pull)"
Write-Step "stop old services / free ports"
try {
# stop web/worker containers but keep volume; full down then local mysql up is ok
docker compose -f (Join-Path $Root "docker-compose.yml") down --remove-orphans 2>$null | Out-Null
Write-Host " docker compose down done"
} catch {
Write-Host " docker compose down skipped: $($_.Exception.Message)" -ForegroundColor Yellow
}
Stop-NodeDevProcesses
Stop-PortListeners -Port $WebPort
# do not kill 7023 if we will reuse mysql quickly; still free stale non-docker holders
Stop-PortListeners -Port $MysqlPort
Ensure-DotEnv -RepoRoot $Root
if ($Mode -eq "compose") {
try {
Start-ComposeStack -NoBuild:$NoBuild -WebPort $WebPort -HealthTimeoutSec $HealthTimeoutSec
} catch {
Write-Host ""
Write-Host "compose failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host "Likely cause: cannot reach registry-1.docker.io (node image pull)." -ForegroundColor Yellow
if ($NoFallback) { throw }
Write-Host "Auto-fallback to local mode (mysql + host pnpm)..." -ForegroundColor Cyan
Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec
}
} else {
Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec
}
Write-Host ""
Write-Host "OK - system started (web + worker)" -ForegroundColor Green
Write-Host " open: http://localhost:$WebPort"
Write-Host " login: admin/admin123 or ops/ops123"
Write-Host " worker: IMAP poll / parse / retention (see data\logs\worker.*.log in local mode)"
Write-Host " stop local: kill node on port $WebPort (and worker); docker compose stop mysql"
Write-Host " full compose (needs Docker Hub): .\docs\start-system.ps1 -Mode compose"
Write-Host " clear imap lock: pnpm exec tsx scripts/clear-imap-lock.ts"