#Requires -Version 5.1 <# One-command deploy for yelaymaths_api / yelaymaths_admin. Packs source files locally, uploads over SSH, builds on the server and restarts the process. No service is installed on the server; it reuses the SSH access you already have. Usage: .\deploy\deploy.ps1 api .\deploy\deploy.ps1 admin .\deploy\deploy.ps1 all .\deploy\deploy.ps1 api -DryRun # pack only, show what would be uploaded .\deploy\deploy.ps1 api -WithInstall # also install dependencies (only after package.json changes) Files are packed from an explicit whitelist, so .env / node_modules / dist / .next are never uploaded and your server-side .env stays untouched. #> [CmdletBinding()] param( [ValidateSet('api', 'admin', 'all')] [string]$Target = 'all', [switch]$DryRun, [switch]$WithInstall ) $ErrorActionPreference = 'Stop' $OutputEncoding = New-Object System.Text.UTF8Encoding $false $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $scriptDir $configPath = Join-Path $scriptDir 'deploy.config.ps1' if (-not (Test-Path $configPath)) { Write-Host 'ERROR: deploy.config.ps1 not found.' -ForegroundColor Red Write-Host 'Copy deploy.config.example.ps1 to deploy.config.ps1 and fill in your server details.' exit 1 } . $configPath foreach ($exe in @('ssh', 'scp', 'tar')) { if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { throw "Missing required command: $exe (Windows 10+ ships OpenSSH and tar)" } } $sshTarget = '{0}@{1}' -f $Config.User, $Config.Host function Invoke-AppDeploy { param([hashtable]$App) $local = Join-Path $repoRoot $App.LocalPath if (-not (Test-Path $local)) { throw "Local path not found: $local" } # Whitelist: only include paths that exist. Everything else (secrets, build output) # is structurally impossible to upload. $items = @() foreach ($p in $App.DeployPaths) { if (Test-Path (Join-Path $local $p)) { $items += $p } } if ($items.Count -eq 0) { throw "Nothing to deploy for $($App.Key)" } $excludes = @() foreach ($p in ($App.ExcludePaths ?? @())) { $excludes += "--exclude=$p" } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $tarName = 'yelay-{0}-{1}.tar.gz' -f $App.Key, $stamp $tarFile = Join-Path $env:TEMP $tarName $remoteTar = '/tmp/' + $tarName Write-Host '' Write-Host ('=== {0} ===' -f $App.Label) -ForegroundColor Cyan # ---- 本地构建:把耗时的编译放在本机,服务器只接收产物 ---- if ($App.LocalBuildCmd) { Write-Host ('[1/5] Building locally: {0}' -f $App.LocalBuildCmd) # 国内拉取 Prisma 引擎很慢,指向镜像 $env:PRISMA_ENGINES_MIRROR = 'https://registry.npmmirror.com/-/binary/prisma' Push-Location $local try { Invoke-Expression $App.LocalBuildCmd if ($LASTEXITCODE -ne 0) { throw "Local build failed: $($App.LocalBuildCmd)" } } finally { Pop-Location } } else { Write-Host '[1/5] Skipping local build' } Write-Host ('[2/5] Packing {0}' -f $App.LocalPath) Push-Location $local try { & tar -czf $tarFile @excludes @items if ($LASTEXITCODE -ne 0) { throw 'tar failed' } } finally { Pop-Location } $sizeKb = [math]::Round((Get-Item $tarFile).Length / 1KB, 1) Write-Host (' {0} KB ({1})' -f $sizeKb, ($items -join ', ')) if ($DryRun) { Write-Host ' DryRun: nothing uploaded.' Remove-Item $tarFile -Force return } Write-Host ('[3/5] Uploading to {0}' -f $App.RemotePath) & scp -P $Config.Port -q $tarFile ('{0}:{1}' -f $sshTarget, $remoteTar) if ($LASTEXITCODE -ne 0) { throw 'scp failed' } $install = '' if ($WithInstall) { $install = $App.InstallCmd } # 部署前先在服务器上删掉指定的旧目录。 # # 为什么必须要这一步:Next.js 会把预渲染好的页面缓存在 .next/cache 里, # 命中时直接返回缓存(响应头 x-nextjs-cache: HIT),不再读磁盘上的新文件。 # 而 .next/cache 体积很大(约 73MB),上传时会排除掉,于是它永远是旧的 —— # 表现就是「代码传上去了、也重启了,页面却一直没变」。这里先删掉,让它重建。 $cleanCmd = '' foreach ($p in ($App.CleanPaths ?? @())) { $cleanCmd += "rm -rf '$($App.RemotePath)/$p'`n" } # 部署后清空 Nginx 反代缓存。 # # 为什么必须清:宝塔在 /www/server/nginx/conf/proxy.conf 里全局开了 # proxy_cache cache_one,而 Next.js 给静态页面发的响应头是 # Cache-Control: s-maxage=31536000,等于告诉 Nginx「这一页可以存一年」。 # 于是新构建传上去、进程也重启了,访问到的还是缓存的旧 HTML;旧 HTML # 引用的分包早被新构建删掉,一请求就是 404,页面渲染到一半就断, # 表现就是「怎么改都不生效」,甚至某个区块整块消失。 # 清掉缓存目录再 reload,让它回源读新构建。 $nginxCacheCmd = '' if ($Config.NginxCacheDir) { $nginxCacheCmd += "find '$($Config.NginxCacheDir)' -mindepth 1 -delete 2>/dev/null || true`n" if ($Config.NginxBin) { $nginxCacheCmd += "'$($Config.NginxBin)' -s reload 2>/dev/null || true`n" } } # 启动命令。 # # RestartCmd 只负责杀掉旧进程,宝塔不会自动把新进程拉起来, # 所以这里自己拉。先 sleep 2 是为了让刚被杀掉的进程彻底放开端口, # 否则新进程会 EADDRINUSE 起不来。 $startCmd = '' if ($App.StartCmd) { $startCmd = "sleep 2`n$($App.StartCmd)`n" } # 启动后探活:确认端口真的在响应,而不是「命令敲下去就当成功」。 # 30 秒还没起来就让部署直接失败,免得又出现「显示部署成功、线上却是 502」。 $healthCmd = '' if ($App.HealthUrl) { $healthCmd = "for i in {1..30}; do curl -sf -o /dev/null '$($App.HealthUrl)' && break; sleep 1; done`n" $healthCmd += "curl -sf -o /dev/null '$($App.HealthUrl)' || { echo 'ERROR: service did not come up: $($App.HealthUrl)'; exit 1; }`n" } # Remote script is intentionally ASCII-only to avoid encoding issues over SSH. $remoteScript = @" set -e export PATH=$($Config.NodeBin):`$PATH mkdir -p '$($App.RemotePath)' cd '$($App.RemotePath)' $cleanCmd tar -xzf '$remoteTar' rm -f '$remoteTar' $install $($App.BuildCmd) $($App.RestartCmd) $startCmd $healthCmd $nginxCacheCmd echo '__DEPLOY_DONE__' "@ Write-Host '[4/5] Running on server' $remoteScript | & ssh -p $Config.Port $sshTarget 'bash -s' if ($LASTEXITCODE -ne 0) { throw 'remote build or restart failed' } Remove-Item $tarFile -Force -ErrorAction SilentlyContinue if ($App.RestartCmd) { Write-Host '[5/5] Restarted' -ForegroundColor Green } else { Write-Host '[5/5] Done' -ForegroundColor Green Write-Host (' NOTE: restart "{0}" from the BT panel now.' -f $App.Label) -ForegroundColor Yellow } if ($App.StartCmd) { Write-Host (' Started, health check passed: {0}' -f $App.HealthUrl) -ForegroundColor Green } if ($nginxCacheCmd) { Write-Host ' Nginx proxy cache cleared and reloaded' -ForegroundColor Green } } $apps = switch ($Target) { 'api' { @($Config.Api) } 'admin' { @($Config.Admin) } 'all' { @($Config.Api, $Config.Admin) } } $started = Get-Date foreach ($app in $apps) { Invoke-AppDeploy -App $app } if (-not $DryRun) { Write-Host '' Write-Host ('All done in {0:N1}s' -f ((Get-Date) - $started).TotalSeconds) -ForegroundColor Green }