zscripts-token-savers/zbackup.ps1
KellyMichels a7b8712063 fix(ps): zbackup reports the real pg_dump failure, not "No DATABASE_URL"
Mirror of the bash fix. Invoke-LocalPgDump now owns all its messaging
(caller just captures success) and distinguishes the cases:

- no .env / no DATABASE_URL -> calm "No local DATABASE_URL".
- database not reachable (connection refused / could not connect / DNS /
  timeout) -> calm "Local database not running at host:port" - a stopped
  dev DB is a normal state.
- any other failure (version mismatch, auth, missing db) -> the loud,
  full pg_dump error plus the host:port/db it tried, instead of a bare
  "pg_dump failed (exit N)" followed by a misleading "No DATABASE_URL".

Captures pg_dump stderr (was 2>$null); drops ErrorActionPreference to
Continue locally so PS 5.1 doesn't turn that stderr into a terminating
NativeCommandError under the script's 'Stop' setting.
2026-07-23 00:18:38 -05:00

238 lines
9.7 KiB
PowerShell

# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels · dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
# zbackup.ps1 — local backups: zip project sources (plus a Postgres dump when the
# project's .env has a DATABASE_URL) into the backups folder.
#
# Usage:
# zbackup <project> [<project> ...]
# zbackup all # every project in zconfig.json + this scripts folder
# zbackup scripts # just this scripts folder ('scripts' is a reserved word)
# zbackup pyapp -Tag "pre-migration"
#
# Output: <paths.backupsLocal>\<project>\<timestamp>_<project>[_tag].zip
param(
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$Projects = @(),
[string]$Tag = ""
)
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "ZHelpers.ps1")
Start-ZTracking
$cfg = Get-ZConfig
$ProjectsBackupRoot = $cfg.paths.backupsLocal
# Tolerate switch-style args (zbackup -myproject) from muscle memory.
$Projects = @($Projects | ForEach-Object { $_.TrimStart('-') })
# No args shows usage instead of quietly backing up everything — 'all' is
# explicit, matching zdeploy and the other z-commands.
if ($Projects.Count -eq 0) {
$keys = (Get-ZProjectKeys) -join ', '
Write-Host ""
Write-Host "Usage: zbackup <project> [<project> ...] | all | scripts [-Tag `"label`"]" -ForegroundColor Yellow
Write-Host " Projects in zconfig.json: $keys" -ForegroundColor Gray
Write-Host " 'all' backs up every project plus this scripts folder; 'scripts' just this folder." -ForegroundColor Gray
Stop-ZTracking; exit 1
}
$includeScripts = $false
if ($Projects -contains 'all') {
$Projects = @(Get-ZProjectKeys)
$includeScripts = $true
} elseif ($Projects -contains 'scripts') {
$Projects = @($Projects | Where-Object { $_ -ne 'scripts' })
$includeScripts = $true
}
function Get-BackupTimestamp { return Get-Date -Format "yyyyMMdd-HHmmss" }
function Get-TagSuffix {
if ([string]::IsNullOrWhiteSpace($Tag)) { return "" }
return "_" + ($Tag.Trim() -replace '\s+', '_')
}
function Ensure-BackupDir {
param([string]$ProjectKey)
$dir = Join-Path $ProjectsBackupRoot $ProjectKey
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
return $dir
}
# Dump the project's Postgres database if its .env declares a DATABASE_URL.
# Checks <root>\.env, then <root>\backend\.env (frontend/backend split projects).
# Prints its own status line for every outcome; returns $true (dumped) or $false.
# A not-running database (connection refused) is a calm, expected skip; a real
# failure (version mismatch, auth, missing db) prints the loud pg_dump error.
function Invoke-LocalPgDump {
param([string]$Root, [string]$OutPath)
$envFile = Join-Path $Root ".env"
if (-not (Test-Path -LiteralPath $envFile)) { $envFile = Join-Path $Root "backend\.env" }
if (-not (Test-Path -LiteralPath $envFile)) {
Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray; return $false
}
$dbLine = @(Get-Content -LiteralPath $envFile | Where-Object { $_ -match "^DATABASE_URL=" } | Select-Object -First 1)
if ($dbLine.Count -eq 0) {
Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray; return $false
}
# Strip surrounding quotes (Prisma-style .env values are double-quoted),
# accept postgres:// and postgresql+driver:// schemes, and treat the
# port as optional (Postgres default 5432).
$dbUrl = ($dbLine[0] -replace "^DATABASE_URL=", "").Trim().Trim('"').Trim("'")
$dbUrl = $dbUrl -replace '^postgres(ql)?(\+[^:]+)?://', 'postgresql://'
$rx = [regex]::Match(
$dbUrl,
'^postgresql://(?<user>[^:@/]+):(?<pass>[^@]+)@(?<host>[^:/?]+)(:(?<port>\d+))?/(?<db>[^?\s]+)'
)
if (-not $rx.Success) {
Write-Host ' Could not parse DATABASE_URL - skipping PG backup' -ForegroundColor Red
return $false
}
$env:PGPASSWORD = [Uri]::UnescapeDataString($rx.Groups['pass'].Value)
$pgUser = $rx.Groups['user'].Value
$pgHost = $rx.Groups['host'].Value
$pgPort = if ($rx.Groups['port'].Success) { $rx.Groups['port'].Value } else { '5432' }
$pgDb = $rx.Groups['db'].Value
$pgDump = "pg_dump"
$pgBinPaths = @(
"C:\Program Files\PostgreSQL\18\bin\pg_dump.exe",
"C:\Program Files\PostgreSQL\17\bin\pg_dump.exe",
"C:\Program Files\PostgreSQL\16\bin\pg_dump.exe",
"C:\Program Files\PostgreSQL\15\bin\pg_dump.exe"
)
foreach ($candidate in $pgBinPaths) {
if (Test-Path $candidate) { $pgDump = $candidate; break }
}
# Capture stderr so a failure is diagnosable (version mismatch, unreachable
# host, auth) instead of a bare "pg_dump failed". PS 5.1 turns native stderr
# into a terminating NativeCommandError under 'Stop', so drop to Continue
# locally (function-scoped, auto-reverts) while running pg_dump.
$ErrorActionPreference = 'Continue'
$errOut = & $pgDump -h $pgHost -p $pgPort -U $pgUser -d $pgDb -F p -f $OutPath 2>&1
$ok = ($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $OutPath)
Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue
if ($ok) {
$size = [math]::Round((Get-Item $OutPath).Length / 1KB, 1)
Write-Host " PostgreSQL dump: ${size} KB" -ForegroundColor Green
return $true
}
$errText = ($errOut | Out-String).Trim()
# Not reachable (usually just not running locally) is expected - stay calm.
if ($errText -match '(?i)connection refused|could not connect|no route to host|could not translate host|timeout expired') {
Write-Host " Local database not running at ${pgHost}:${pgPort} - source-only backup." -ForegroundColor DarkGray
return $false
}
# A real failure (version mismatch, auth, missing db) - show the reason.
Write-Host " pg_dump failed (${pgHost}:${pgPort}/${pgDb} as ${pgUser}):" -ForegroundColor Red
foreach ($line in ($errText -split '\r?\n' | Where-Object { $_.Trim() -ne '' })) {
Write-Host " $line" -ForegroundColor Red
}
return $false
}
function Invoke-ProjectBackup {
param([string]$Key)
$proj = Get-ZProject -Key $Key
$root = $proj.localRoot
$ts = Get-BackupTimestamp
$suffix = Get-TagSuffix
$outDir = Ensure-BackupDir -ProjectKey $Key
$zipPath = Join-Path $outDir "${ts}_${Key}${suffix}.zip"
Write-Host ""
Write-Host "=== [$($Key.ToUpper())] Local backup ($($proj.label)) ===" -ForegroundColor Cyan
Write-Host " Output: $zipPath" -ForegroundColor Gray
if (-not (Test-Path -LiteralPath $root)) { throw "$($proj.label) root not found: $root" }
$dumpDir = Join-Path $env:TEMP "zbackup_${Key}_dump_$ts"
if (Test-Path $dumpDir) { Remove-Item $dumpDir -Recurse -Force }
New-Item -ItemType Directory -Path $dumpDir -Force | Out-Null
try {
$extraFiles = @()
Write-Host " [1/3] Checking for a local database to dump..." -ForegroundColor Yellow
$dbDumpPath = Join-Path $dumpDir "database_pg.sql"
if (Invoke-LocalPgDump -Root $root -OutPath $dbDumpPath) {
$extraFiles += $dbDumpPath
}
Write-Host " [2/3] Archiving source..." -ForegroundColor Yellow
New-ProjectArchive -SourcePath $root -DestinationZip $zipPath `
-TopLevelExclude (Get-ArchiveExcludes -Project $proj -ForBackup) -ExtraFiles $extraFiles
Write-Host " [3/3] Done." -ForegroundColor Yellow
$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
return @{ ok = $true; path = $zipPath; sizeMb = $zipSize }
}
finally {
if (Test-Path $dumpDir) { Remove-Item $dumpDir -Recurse -Force -ErrorAction SilentlyContinue }
}
}
function Invoke-ScriptsBackup {
$ts = Get-BackupTimestamp
$suffix = Get-TagSuffix
$outDir = Ensure-BackupDir -ProjectKey "scripts"
$zipPath = Join-Path $outDir "${ts}_scripts${suffix}.zip"
$scriptsRoot = $cfg.paths.scriptsRoot
Write-Host ""
Write-Host "=== [SCRIPTS] Local backup (this scripts folder) ===" -ForegroundColor Cyan
Write-Host " Output: $zipPath" -ForegroundColor Gray
if (-not (Test-Path -LiteralPath $scriptsRoot)) { throw "Scripts root not found: $scriptsRoot" }
Write-Host " [1/2] Archiving source..." -ForegroundColor Yellow
New-ProjectArchive -SourcePath $scriptsRoot -DestinationZip $zipPath `
-TopLevelExclude @(".git", "archive", "tmp", "nul") -IncludeScriptFiles
Write-Host " [2/2] Done." -ForegroundColor Yellow
$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
return @{ ok = $true; path = $zipPath; sizeMb = $zipSize }
}
$exitCode = 0
$results = @()
foreach ($key in $Projects) {
try {
$results += @{ project = $key; result = Invoke-ProjectBackup -Key $key }
} catch {
Write-Host " FAILED [$key]: $($_.Exception.Message)" -ForegroundColor Red
$results += @{ project = $key; failed = $_.Exception.Message }
if ($exitCode -eq 0) { $exitCode = 1 }
}
}
if ($includeScripts) {
try {
$results += @{ project = "scripts"; result = Invoke-ScriptsBackup }
} catch {
Write-Host " FAILED [scripts]: $($_.Exception.Message)" -ForegroundColor Red
$results += @{ project = "scripts"; failed = $_.Exception.Message }
if ($exitCode -eq 0) { $exitCode = 1 }
}
}
Write-Host ""
Write-Host "=== Backup summary ===" -ForegroundColor Cyan
foreach ($r in $results) {
if ($r.result) {
Write-Host (' {0} -> {1} ({2} MB)' -f $r.project, $r.result.path, $r.result.sizeMb) -ForegroundColor Green
} else {
Write-Host (' {0} -> FAILED: {1}' -f $r.project, $r.failed) -ForegroundColor Red
}
}
Write-Host ""
Stop-ZTracking; exit $exitCode