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
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"
|