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