From 845e89a9b3e62d4b3220dbe0a742aea607523a8b Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sat, 25 Jul 2026 15:17:12 -0500 Subject: [PATCH 1/6] =?UTF-8?q?fix(bash):=20zbackup=20=E2=80=94=20real=20p?= =?UTF-8?q?g=5Fdump=20error=20reporting=20+=20require=20an=20explicit=20ta?= =?UTF-8?q?rget=20(all)=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bash): zbackup reports the real pg_dump failure, not "No DATABASE_URL" local_pg_dump returned 1 for four different cases (no .env, no DATABASE_URL, unparseable URL, and an actual dump failure), and the caller printed "No local DATABASE_URL - source-only backup" for every one. So a project that HAS a DATABASE_URL whose dump failed was mislabeled as having none — the two messages contradicted each other. - Distinguish the cases with exit codes: 0 dumped, 1 attempted-but-failed, 2 no local database. The caller now prints the right line for each. - Stop swallowing pg_dump's stderr (2>/dev/null); on failure, surface the actual error (version mismatch, unreachable host, auth) plus the host:port/db it tried, so failures are diagnosable. * fix(bash): zbackup treats a not-running local DB as a calm skip Fold all pg_dump messaging into local_pg_dump (caller just captures success) and special-case an unreachable database. "connection refused" / "could not connect" / DNS / timeout now print a quiet "Local database not running at host:port - source-only backup." instead of a red multi-line error - a stopped dev DB is a normal state. Real failures (version mismatch, auth, missing db) still print the full pg_dump error so they're diagnosable. * fix(bash): zbackup/zbackup_and_sync require an explicit target, matching PowerShell Bare `zbackup` quietly backed up every project - inconsistent with the PowerShell version (and with zdeploy), and an easy way to kick off a huge unintended backup. Now a bare invocation prints usage and lists the projects; `all` does what bare used to (every project + the scripts folder). Same for `zbackup_and_sync`, and setup_backup_schedule's cron line now passes `all` so the scheduled job still backs everything up. --- bash/setup_backup_schedule | 2 +- bash/zbackup | 41 ++++++++++++++++++++++++++++---------- bash/zbackup_and_sync | 12 +++++++++-- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/bash/setup_backup_schedule b/bash/setup_backup_schedule index e15f0d7..48a0e72 100644 --- a/bash/setup_backup_schedule +++ b/bash/setup_backup_schedule @@ -30,7 +30,7 @@ if ! [[ "$hh" =~ ^[0-9]{1,2}$ && "$mm" =~ ^[0-9]{2}$ ]] || [ "$hh" -gt 23 ] || [ err "Invalid --time '$at_time' (expected HH:MM, 24h)"; exit 1 fi -cron_line="${mm#0} ${hh#0} * * * $_HERE/zbackup_and_sync >> \$HOME/zbackup_and_sync.log 2>&1" +cron_line="${mm#0} ${hh#0} * * * $_HERE/zbackup_and_sync all >> \$HOME/zbackup_and_sync.log 2>&1" info "=== Backup schedule (cron) ===" dim "Daily at $at_time:" diff --git a/bash/zbackup b/bash/zbackup index 021cd52..31c5cc2 100644 --- a/bash/zbackup +++ b/bash/zbackup @@ -7,8 +7,8 @@ # project's .env has a DATABASE_URL) into the backups folder. # # Usage: -# zbackup # every project in zconfig.json + this scripts folder # zbackup [ ...] +# 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" # @@ -30,8 +30,18 @@ while [ $# -gt 0 ]; do done BACKUP_ROOT="$(z_path "$(zq '.paths.backupsLocal')")" -include_scripts=0 + +# Require an explicit target: bare invocation shows usage instead of quietly +# backing up everything - 'all' is explicit, matching zdeploy. if [ "${#projects[@]}" -eq 0 ]; then + printf '\n'; warn "Usage: zbackup [ ...] | all | scripts [--tag \"label\"]" + dim " Projects in zconfig.json: $(zproj_csv)" + dim " 'all' backs up every project plus this scripts folder; 'scripts' just this folder." + exit 1 +fi + +include_scripts=0 +if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys) include_scripts=1 else @@ -46,24 +56,34 @@ ts() { date +%Y%m%d-%H%M%S; } tag_suffix() { [ -n "$tag" ] && printf '_%s' "$(printf '%s' "$tag" | tr -s '[:space:]' '_')"; } # Dump the project's Postgres database if its .env declares a DATABASE_URL. -# Checks /.env, then /backend/.env. Returns 0 and writes $2 on success. +# Checks /.env, then /backend/.env. Prints its own status line for +# every outcome; returns 0 (dumped, $2 written) or 1 (nothing dumped). +# 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. local_pg_dump() { # - local root="$1" out="$2" env_file db_url + local root="$1" out="$2" env_file db_url err_out rc env_file="$root/.env"; [ -f "$env_file" ] || env_file="$root/backend/.env" - [ -f "$env_file" ] || return 1 + if [ ! -f "$env_file" ]; then dim " No local DATABASE_URL - source-only backup."; return 1; fi db_url="$(grep -m1 '^DATABASE_URL=' "$env_file" | cut -d= -f2- | tr -d '[:space:]')" - [ -n "$db_url" ] || return 1 + if [ -z "$db_url" ]; then dim " No local DATABASE_URL - source-only backup."; return 1; fi db_url="$(printf '%s' "$db_url" | sed -E 's|^postgresql\+[^:]+://|postgresql://|')" if [[ "$db_url" =~ ^postgresql://([^:]+):([^@]+)@([^:]+):([0-9]+)/([^?]+) ]]; then local user="${BASH_REMATCH[1]}" pass="${BASH_REMATCH[2]}" host="${BASH_REMATCH[3]}" local port="${BASH_REMATCH[4]}" db="${BASH_REMATCH[5]}" command -v pg_dump >/dev/null 2>&1 || { err ' pg_dump not installed - skipping PG backup'; return 1; } - if PGPASSWORD="$(z_urldecode "$pass")" pg_dump -h "$host" -p "$port" -U "$user" -d "$db" -F p -f "$out" 2>/dev/null \ - && [ -f "$out" ]; then + err_out="$(PGPASSWORD="$(z_urldecode "$pass")" pg_dump -h "$host" -p "$port" -U "$user" -d "$db" -F p -f "$out" 2>&1)"; rc=$? + if [ "$rc" -eq 0 ] && [ -f "$out" ]; then ok " PostgreSQL dump: $(awk -v b="$(wc -c < "$out")" 'BEGIN{printf "%.1f", b/1024}') KB" return 0 fi - err " pg_dump failed" + # Not reachable (usually just not running locally) is expected - stay calm. + if printf '%s' "$err_out" | grep -qiE 'connection refused|could not connect|no route to host|could not translate host|timeout expired'; then + dim " Local database not running at $host:$port - source-only backup." + return 1 + fi + # A real failure (version mismatch, auth, missing db) - show the reason. + err " pg_dump failed ($host:$port/$db as $user):" + printf '%s\n' "$err_out" | grep -v '^[[:space:]]*$' | sed 's/^/ /' >&2 return 1 fi err ' Could not parse DATABASE_URL - skipping PG backup' @@ -87,8 +107,7 @@ backup_project() { # dump_dir="$(mktemp -d "${TMPDIR:-/tmp}/zbackup_${key}_XXXXXX")" db_dump="$dump_dir/database_pg.sql" warn " [1/3] Checking for a local database to dump..." - if local_pg_dump "$root" "$db_dump"; then extra="$db_dump" - else dim " No local DATABASE_URL - source-only backup."; fi + if local_pg_dump "$root" "$db_dump"; then extra="$db_dump"; fi warn " [2/3] Archiving source..." local excl=() diff --git a/bash/zbackup_and_sync b/bash/zbackup_and_sync index 497c37b..06bbcec 100644 --- a/bash/zbackup_and_sync +++ b/bash/zbackup_and_sync @@ -6,11 +6,11 @@ # zbackup_and_sync — run backups, then sync the backups folder offsite. # # Usage: -# zbackup_and_sync # backup everything + sync # zbackup_and_sync [ ...] +# zbackup_and_sync all # backup everything + sync # # Cron example (see setup_backup_schedule): -# 0 2 * * * /path/to/zscripts/bash/zbackup_and_sync >> ~/zbackup.log 2>&1 +# 0 2 * * * /path/to/zscripts/bash/zbackup_and_sync all >> ~/zbackup.log 2>&1 set -uo pipefail _HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -18,6 +18,14 @@ source "$_HERE/zhelpers.sh" z_track_start "$@" z_need_config +# Require an explicit target (matching zbackup/zdeploy); 'all' backs up everything. +if [ $# -eq 0 ]; then + printf '\n'; warn "Usage: zbackup_and_sync [ ...] | all" + dim " Projects in zconfig.json: $(zproj_csv)" + dim " 'all' backs up every project plus the scripts folder, then syncs offsite." + exit 1 +fi + printf '\n' info "============================================" info " Backup & Sync - $(date '+%Y-%m-%d %H:%M:%S')" From b370a46d799378684984f34744f9f92103d87d34 Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sat, 25 Jul 2026 15:21:18 -0500 Subject: [PATCH 2/6] fix: zbackup_ec2/zec2/zec2online require an explicit target (all), matching zbackup (#11) These three defaulted to "every project" when run with no arguments - inconsistent with zbackup/zdeploy/zstart/etc. (which show usage), and a surprising amount of work to kick off by accident: zbackup_ec2 pulls a server backup of every project, and zec2online deep-checks everything AND auto-starts any downed stacks. Now a bare invocation prints usage and lists the projects; 'all' does what bare used to. Applied to both the bash and PowerShell versions. --- bash/zbackup_ec2 | 9 ++++++++- bash/zec2 | 8 +++++++- bash/zec2online | 8 +++++++- zbackup_ec2.ps1 | 9 ++++++++- zec2.ps1 | 9 ++++++++- zec2online.ps1 | 9 ++++++++- 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/bash/zbackup_ec2 b/bash/zbackup_ec2 index be3df82..56bc23d 100644 --- a/bash/zbackup_ec2 +++ b/bash/zbackup_ec2 @@ -7,8 +7,8 @@ # with a "db" config block, plus a zip of server-side data dirs (uploads/archive/dist). # # Usage: -# zbackup_ec2 # every project with a remote.path # zbackup_ec2 [ ...] +# zbackup_ec2 all # every project with a remote.path # # Output: # //__db.sql (projects with a db block) @@ -21,7 +21,14 @@ z_need_config z_require ssh scp projects=("$@") +# Require an explicit target; 'all' pulls every project with a remote.path. if [ "${#projects[@]}" -eq 0 ]; then + printf '\n'; warn "Usage: zbackup_ec2 [ ...] | all" + dim " Projects in zconfig.json: $(zproj_csv)" + dim " 'all' pulls a server backup of every project with a remote.path." + exit 1 +fi +if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done \ < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.remote.path != null) | .key' "$ZCONFIG") fi diff --git a/bash/zec2 b/bash/zec2 index a991bee..94d8664 100644 --- a/bash/zec2 +++ b/bash/zec2 @@ -6,8 +6,8 @@ # zec2 — quick reachability check (TCP + HTTP + live build version) for deployed projects. # # Usage: -# zec2 # every project with a "domain" in zconfig.json # zec2 [ ...] +# zec2 all # every project with a "domain" in zconfig.json # zec2 viteapp --host 203.0.113.10 set -uo pipefail @@ -28,6 +28,12 @@ done HOST="${host_override:-$(zec2_ip)}" if [ "${#projects[@]}" -eq 0 ]; then + printf '\n'; warn "Usage: zec2 [ ...] | all [--host ]" + dim " Projects in zconfig.json: $(zproj_csv)" + dim " 'all' checks every project with a 'domain' configured." + exit 1 +fi +if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys_with_domain) if [ "${#projects[@]}" -eq 0 ]; then warn "No projects with a 'domain' configured in zconfig.json." diff --git a/bash/zec2online b/bash/zec2online index e85c246..31b92e4 100644 --- a/bash/zec2online +++ b/bash/zec2online @@ -7,8 +7,8 @@ # build; auto-start downed stacks via docker compose and stream diagnostics. # # Usage: -# zec2online # every project with a "domain" in zconfig.json # zec2online [ ...] +# zec2online all # every project with a "domain" in zconfig.json # # A plain HTTP-200 check passes even when a stale cached build is live — the # local-vs-server version match is what proves the deployed build is the one running. @@ -32,6 +32,12 @@ HOST="${host_override:-$(zec2_ip)}" EDGE_KEY="$(zedge_key)" if [ "${#projects[@]}" -eq 0 ]; then + printf '\n'; warn "Usage: zec2online [ ...] | all [--host ]" + dim " Projects in zconfig.json: $(zproj_csv)" + dim " 'all' deep-checks every project with a 'domain' (and auto-starts any that are down)." + exit 1 +fi +if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys_with_domain) if [ "${#projects[@]}" -eq 0 ]; then warn "No projects with a 'domain' configured in zconfig.json." diff --git a/zbackup_ec2.ps1 b/zbackup_ec2.ps1 index ef94029..7d017bd 100644 --- a/zbackup_ec2.ps1 +++ b/zbackup_ec2.ps1 @@ -6,8 +6,8 @@ # with a "db" config block, plus a zip of server-side data dirs (uploads/archive/dist). # # Usage: -# zbackup_ec2 # every project with a remote.path # zbackup_ec2 [ ...] +# zbackup_ec2 all # every project with a remote.path # # Output: # \\__db.sql (projects with a db block) @@ -29,6 +29,13 @@ $RemoteHome = Get-Ec2Home $Ec2BackupRoot = $cfg.paths.backupsEc2 if ($Projects.Count -eq 0) { + Write-Host "" + Write-Host "Usage: zbackup_ec2 [ ...] | all" -ForegroundColor Yellow + Write-Host " Projects in zconfig.json: $((Get-ZProjectKeys) -join ', ')" -ForegroundColor Gray + Write-Host " 'all' pulls a server backup of every project with a remote.path." -ForegroundColor Gray + Stop-ZTracking; exit 1 +} +if ($Projects -contains 'all') { $Projects = @(Get-ZProjectKeys | Where-Object { $cfg.projects.$_.remote -and $cfg.projects.$_.remote.path }) } diff --git a/zec2.ps1 b/zec2.ps1 index 2db49f7..d5ecbe0 100644 --- a/zec2.ps1 +++ b/zec2.ps1 @@ -5,8 +5,8 @@ # zec2.ps1 — quick reachability check (TCP + HTTP + live build version) for deployed projects. # # Usage: -# zec2 # every project with a "domain" in zconfig.json # zec2 [ ...] +# zec2 all # every project with a "domain" in zconfig.json # zec2 viteapp -HostName 203.0.113.10 # param( @@ -23,6 +23,13 @@ $cfg = Get-ZConfig if (-not $HostName) { $HostName = $cfg.ec2.ip } if ($Projects.Count -eq 0) { + Write-Host "" + Write-Host "Usage: zec2 [ ...] | all [-HostName ]" -ForegroundColor Yellow + Write-Host " Projects in zconfig.json: $((Get-ZProjectKeys) -join ', ')" -ForegroundColor Gray + Write-Host " 'all' checks every project with a 'domain' configured." -ForegroundColor Gray + Stop-ZTracking; exit 1 +} +if ($Projects -contains 'all') { $Projects = @(Get-ZProjectKeys | Where-Object { $cfg.projects.$_.domain }) if ($Projects.Count -eq 0) { Write-Host "No projects with a 'domain' configured in zconfig.json." -ForegroundColor Yellow diff --git a/zec2online.ps1 b/zec2online.ps1 index 71e2d4f..4143eea 100644 --- a/zec2online.ps1 +++ b/zec2online.ps1 @@ -6,8 +6,8 @@ # build; auto-start downed stacks via docker compose and stream diagnostics. # # Usage: -# zec2online # every project with a "domain" in zconfig.json # zec2online [ ...] +# zec2online all # every project with a "domain" in zconfig.json # # Verification compares the LOCAL build version against what the server is actually # serving. A plain HTTP-200 check passes even when a stale cached build is live — @@ -30,6 +30,13 @@ $SshTarget = Get-Ec2Target $edgeProj = Get-ZEdgeProject if ($Projects.Count -eq 0) { + Write-Host "" + Write-Host "Usage: zec2online [ ...] | all [-HostName ]" -ForegroundColor Yellow + Write-Host " Projects in zconfig.json: $((Get-ZProjectKeys) -join ', ')" -ForegroundColor Gray + Write-Host " 'all' deep-checks every project with a 'domain' (and auto-starts any that are down)." -ForegroundColor Gray + Stop-ZTracking; exit 1 +} +if ($Projects -contains 'all') { $Projects = @(Get-ZProjectKeys | Where-Object { $cfg.projects.$_.domain }) if ($Projects.Count -eq 0) { Write-Host "No projects with a 'domain' configured in zconfig.json." -ForegroundColor Yellow From 5844fc161407190212138cb475d1364e44a2d0a5 Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sat, 25 Jul 2026 15:22:04 -0500 Subject: [PATCH 3/6] fix: exclude .env secrets from python/vite deploy zips (not backups) (#23) Only nextjs-kind excluded .env* from the deploy archive; python and vite did not - so a project's local .env at its root got zipped and shipped to the server on every deploy, planting local secrets over the server's own (the operator-file restore only wins for files it preserved). Add .env/.env.local/.env.production to the python and vite deploy excludes, matching nextjs. Kept DEPLOY-only (like `uploads`): backups still capture .env so a source backup stays complete. Bash + PowerShell. Note: this covers a ROOT .env. A nested secret (e.g. DocketMail's backend/.env) is handled separately via deploy.preserve in the project's zconfig. --- ZHelpers.ps1 | 10 ++++++++-- bash/zhelpers.sh | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ZHelpers.ps1 b/ZHelpers.ps1 index 7bd7467..a5127d4 100644 --- a/ZHelpers.ps1 +++ b/ZHelpers.ps1 @@ -177,10 +177,16 @@ function Get-ArchiveExcludes { $byKind = switch ([string]$Project.kind) { "python" { $list = @(".venv", "venv", "__pycache__", ".pytest_cache", ".nicegui", "archive", "dist", "build", "htmlcov") - if (-not $ForBackup) { $list += "uploads" } # deploys exclude user uploads; backups keep them + # Deploys exclude user uploads + local .env secrets (server keeps its + # own, preserved across deploys); backups keep both for completeness. + if (-not $ForBackup) { $list += @("uploads", ".env", ".env.local", ".env.production") } + $list + } + "vite" { + $list = @("node_modules", "dist") + if (-not $ForBackup) { $list += @(".env", ".env.local", ".env.production") } $list } - "vite" { @("node_modules", "dist") } "nextjs" { @("node_modules", ".next", ".env", ".env.local", ".env.production", ".vercel", "coverage", "out", "build", "next-env.d.ts") } default { @() } } diff --git a/bash/zhelpers.sh b/bash/zhelpers.sh index 81d2ae6..9586d57 100644 --- a/bash/zhelpers.sh +++ b/bash/zhelpers.sh @@ -217,8 +217,11 @@ z_archive_excludes() { case "$kind" in python) printf '%s\n' .venv venv __pycache__ .pytest_cache .nicegui archive dist build htmlcov - [ "$for_backup" -eq 1 ] || printf '%s\n' uploads ;; # deploys exclude user uploads; backups keep them - vite) printf '%s\n' node_modules dist ;; + # deploys exclude user uploads + local .env secrets; backups keep both + [ "$for_backup" -eq 1 ] || printf '%s\n' uploads .env .env.local .env.production ;; + vite) + printf '%s\n' node_modules dist + [ "$for_backup" -eq 1 ] || printf '%s\n' .env .env.local .env.production ;; nextjs) printf '%s\n' node_modules .next .env .env.local .env.production .vercel coverage out build next-env.d.ts ;; esac jq -r --arg k "$key" '.projects[$k].deploy.exclude // [] | .[]' "$ZCONFIG" From 50b7c817367a60cde613038c32b5221962899dda Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sat, 25 Jul 2026 22:16:48 -0500 Subject: [PATCH 4/6] fix(ps): zbackup explicit target + robust DATABASE_URL parsing + real pg_dump error reporting (#10) * fix(ps): zbackup explicit target + robust DATABASE_URL parsing; ssh-stderr deploy fix Restores parked, previously-uncommitted PowerShell improvements: - zbackup / zbackup_and_sync require an explicit target: bare invocation now prints usage instead of quietly backing up everything; 'all' does what bare used to (matching zdeploy). setup_backup_schedule.ps1 passes 'all' to the scheduled task; both tolerate switch-style args. - zbackup parses more DATABASE_URL styles: strips surrounding quotes (Prisma convention), accepts postgres:// and postgresql+driver:// schemes, and treats the port as optional (defaults to 5432). - Invoke-Ec2Step survives ssh stderr warnings: under ErrorActionPreference 'Stop', PS 5.1 turns any native stderr line (e.g. Docker's COMPOSE_BAKE deprecation notice) into a terminating NativeCommandError, aborting a deploy that actually succeeded. Drop to Continue locally and flatten stderr so only the real exit code decides success. * 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. --- CHANGELOG.md | 10 ++++++ ZHelpers.ps1 | 11 ++++++- setup_backup_schedule.ps1 | 2 +- zbackup.ps1 | 65 ++++++++++++++++++++++++++++++--------- zbackup_and_sync.ps1 | 22 ++++++++----- 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f575982..fec7eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,16 @@ Notable changes to the Evomedia.net Token Savers. grew up on per-project switches. ### Changed +- **`zbackup` / `zbackup_and_sync` require an explicit target** — running + them bare now shows usage instead of quietly backing up every project; + `all` does what bare invocation used to (matching `zdeploy`). The + scheduled task created by `setup_backup_schedule.ps1` passes `all` — + re-run it if your task was registered before this change. +- **`zbackup` parses more `DATABASE_URL` styles** — double/single-quoted + values (Prisma convention), `postgres://` and `postgresql+driver://` + schemes, and URLs without an explicit port (defaults to 5432) all work; + previously these skipped the Postgres dump with "Could not parse + DATABASE_URL". - **`zkill` / port cleanup kills the whole process tree** — listeners on a project's port are now terminated children-first. Auto-reloading servers (uvicorn/watchfiles, nodemon) spawn workers that inherit the listening diff --git a/ZHelpers.ps1 b/ZHelpers.ps1 index a5127d4..52ae1e1 100644 --- a/ZHelpers.ps1 +++ b/ZHelpers.ps1 @@ -109,7 +109,16 @@ function Invoke-Ec2Step { ) $cfg = Get-ZConfig Write-Host " >> $Label" -ForegroundColor DarkCyan - ssh -o StrictHostKeyChecking=no -i $cfg.ec2.pemKey (Get-Ec2Target) $Bash + # ssh can emit warnings on stderr (e.g. Docker's "COMPOSE_BAKE is + # deprecated" notice during a compose build). The deploy runs under + # ErrorActionPreference='Stop', and PowerShell 5.1 turns any native stderr + # line into a terminating NativeCommandError — aborting the deploy before we + # ever read the real exit code, even though the remote step succeeded. Drop + # to Continue locally (function-scoped, auto-reverts) and flatten stderr + # into normal output, so only the actual exit status decides success. + $ErrorActionPreference = 'Continue' + ssh -o StrictHostKeyChecking=no -i $cfg.ec2.pemKey (Get-Ec2Target) $Bash 2>&1 | + ForEach-Object { "$_" } if ($LASTEXITCODE -ne 0) { $msg = "Remote step failed: '$Label' (exit $LASTEXITCODE)." if ($FailHint) { $msg += " $FailHint" } diff --git a/setup_backup_schedule.ps1 b/setup_backup_schedule.ps1 index c6a15b6..90e904f 100644 --- a/setup_backup_schedule.ps1 +++ b/setup_backup_schedule.ps1 @@ -46,7 +46,7 @@ if ($existingTask) { $trigger = New-ScheduledTaskTrigger -Daily -At "02:00" $action = New-ScheduledTaskAction ` -Execute "powershell.exe" ` - -Argument "-ExecutionPolicy Bypass -NoProfile -File `"$ScriptPath`"" + -Argument "-ExecutionPolicy Bypass -NoProfile -File `"$ScriptPath`" all" $settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` diff --git a/zbackup.ps1 b/zbackup.ps1 index 09c9bfb..a030764 100644 --- a/zbackup.ps1 +++ b/zbackup.ps1 @@ -6,8 +6,8 @@ # project's .env has a DATABASE_URL) into the backups folder. # # Usage: -# zbackup # every project in zconfig.json + this scripts folder # zbackup [ ...] +# 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" # @@ -26,8 +26,22 @@ Start-ZTracking $cfg = Get-ZConfig $ProjectsBackupRoot = $cfg.paths.backupsLocal -$includeScripts = $false +# 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 [ ...] | 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') { @@ -53,19 +67,29 @@ function Ensure-BackupDir { # Dump the project's Postgres database if its .env declares a DATABASE_URL. # Checks \.env, then \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)) { return $false } + 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) { return $false } + if ($dbLine.Count -eq 0) { + Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray; return $false + } - $dbUrl = ($dbLine[0] -replace "^DATABASE_URL=", "").Trim() - $dbUrl = $dbUrl -replace '^postgresql\+[^:]+://', 'postgresql://' + # 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://(?[^:]+):(?[^@]+)@(?[^:]+):(?\d+)/(?[^?]+)' + '^postgresql://(?[^:@/]+):(?[^@]+)@(?[^:/?]+)(:(?\d+))?/(?[^?\s]+)' ) if (-not $rx.Success) { Write-Host ' Could not parse DATABASE_URL - skipping PG backup' -ForegroundColor Red @@ -74,7 +98,7 @@ function Invoke-LocalPgDump { $env:PGPASSWORD = [Uri]::UnescapeDataString($rx.Groups['pass'].Value) $pgUser = $rx.Groups['user'].Value $pgHost = $rx.Groups['host'].Value - $pgPort = $rx.Groups['port'].Value + $pgPort = if ($rx.Groups['port'].Success) { $rx.Groups['port'].Value } else { '5432' } $pgDb = $rx.Groups['db'].Value $pgDump = "pg_dump" @@ -87,16 +111,31 @@ function Invoke-LocalPgDump { foreach ($candidate in $pgBinPaths) { if (Test-Path $candidate) { $pgDump = $candidate; break } } - & $pgDump -h $pgHost -p $pgPort -U $pgUser -d $pgDb -F p -f $OutPath 2>$null + # 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 - } else { - Write-Host " pg_dump failed (exit $LASTEXITCODE)" -ForegroundColor Red + return $true } - return $ok + $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 { @@ -124,8 +163,6 @@ function Invoke-ProjectBackup { $dbDumpPath = Join-Path $dumpDir "database_pg.sql" if (Invoke-LocalPgDump -Root $root -OutPath $dbDumpPath) { $extraFiles += $dbDumpPath - } else { - Write-Host " No local DATABASE_URL - source-only backup." -ForegroundColor DarkGray } Write-Host " [2/3] Archiving source..." -ForegroundColor Yellow diff --git a/zbackup_and_sync.ps1 b/zbackup_and_sync.ps1 index cb0576b..19d9af1 100644 --- a/zbackup_and_sync.ps1 +++ b/zbackup_and_sync.ps1 @@ -5,11 +5,11 @@ # zbackup_and_sync.ps1 — run backups, then sync the backups folder offsite. # # Usage: -# zbackup_and_sync.ps1 # backup everything + sync # zbackup_and_sync.ps1 [ ...] +# zbackup_and_sync.ps1 all # backup everything + sync # # Scheduled Task example (see setup_backup_schedule.ps1): -# powershell -ExecutionPolicy Bypass -NoProfile -File "\zbackup_and_sync.ps1" +# powershell -ExecutionPolicy Bypass -NoProfile -File "\zbackup_and_sync.ps1" all param( [Parameter(Position = 0, ValueFromRemainingArguments = $true)] @@ -21,6 +21,18 @@ $ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Definition . (Join-Path $ScriptRoot "ZHelpers.ps1") Start-ZTracking +# Tolerate switch-style args from muscle memory; require an explicit target +# ('all' included) — same convention as zbackup/zdeploy. +$Projects = @($Projects | ForEach-Object { $_.TrimStart('-') }) +if ($Projects.Count -eq 0) { + $keys = (Get-ZProjectKeys) -join ', ' + Write-Host "" + Write-Host "Usage: zbackup_and_sync [ ...] | all" -ForegroundColor Yellow + Write-Host " Projects in zconfig.json: $keys" -ForegroundColor Gray + Write-Host " 'all' backs up every project plus the scripts folder, then syncs offsite." -ForegroundColor Gray + Stop-ZTracking; exit 1 +} + Write-Host "" Write-Host "============================================" -ForegroundColor Cyan Write-Host " Backup & Sync - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan @@ -29,11 +41,7 @@ Write-Host "" Write-Host "[1/2] Running backups..." -ForegroundColor Yellow $backupPath = Join-Path $ScriptRoot "zbackup.ps1" -if ($Projects.Count -gt 0) { - & powershell -NoProfile -File $backupPath @Projects -} else { - & powershell -NoProfile -File $backupPath -} +& powershell -NoProfile -File $backupPath @Projects $backupExitCode = $LASTEXITCODE if ($backupExitCode -ne 0) { From 32b9f7fba9e3a47b1e52f286953a3f1d81b14748 Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sat, 25 Jul 2026 22:17:14 -0500 Subject: [PATCH 5/6] feat(zstart): uvicorn/startApp support + missing-venv warning + fix --detached hang (#12) * feat(zstart): support uvicorn/ASGI apps via a startApp config field Python projects could only be started as `python -m `, so FastAPI/ASGI apps that run under uvicorn (like evo-ai: `uvicorn app.main:app`) couldn't be started by zstart in either port. Add an optional `startApp` field. When set, zstart runs `uvicorn --host --port --reload` via the venv python's -m (no PATH juggling), integrating zstart's existing bind-host and dev-port handling. startApp takes precedence over startModule; a python project still needs one or the other. Applied to bash and PowerShell, documented in the README + example configs. * feat(zstart): warn when falling back to system python (no project venv) A python project with no .venv (or only a Windows .venv when on WSL) silently ran under the system interpreter, which usually lacks the project's deps - producing a cryptic ModuleNotFoundError far from the cause. Now zstart prints a clear warning naming the missing venv and the one-liner to create it, before starting. Bash + PowerShell. * fix(bash): zstart --detached no longer hangs on the tracking FIFO Detached mode forked the long-lived server while it still inherited the ztokens tracking fds (the capture FIFO on 1/2, saved stdout/stderr on 3/4). The parent's EXIT-trap footer runs `tee` on that FIFO and waits for EOF, which never came while the server held it open - so `zstart --detached` (and zstartd / zrestart --detached) hung instead of returning. detach() now redirects stdin<-/dev/null, stdout/stderr->log and closes fd 3/4 before exec'ing the server. Verified on WSL: detached returns in 0s and the server still boots. * fix(zstart): git-pull pre-step can't hang on a credential prompt start.gitPull ran `git pull --ff-only` before starting the server; in an environment with no cached git credentials (e.g. WSL against an HTTPS GitHub remote) git prompted "Username for 'https://github.com':" and the whole start blocked on stdin. Run the pull with GIT_TERMINAL_PROMPT=0 so it fails fast, log a clear "auto-pull skipped" note, and start with the current checkout. Bash + PowerShell. --- README.md | 3 ++- bash/zconfig.example.json | 1 + bash/zstart | 55 +++++++++++++++++++++++++++++---------- zconfig.example.json | 1 + zstart.ps1 | 44 +++++++++++++++++++++++-------- 5 files changed, 78 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ccc29b2..b667b1b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ The example config ships with sample projects named by their kind — `pyapp`, ` "kind": "python", // python | vite | nextjs | edge | docker "localRoot": "C:\\dev\\myapp", // project folder on this machine "startModule": "myapp.main", // python kind: runs "python -m myapp.main" + // "startApp": "app.main:app", // ...or, for ASGI/FastAPI: uvicorn app.main:app --port --reload "ports": { "dev": 8080, "prod": 3000 }, // local dev port / direct server port "domain": "www.myapp.com", // public domain (health checks + verification) "start": { // optional zstart pre-steps @@ -135,7 +136,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more zstart [ ...] [-Port N] [-BindHost ] [-Detached] ``` -Starts each project's dev server using the handler for its `kind`: **python** runs `python -m ` (preferring the project's `.venv`), **vite** runs `npm run dev -- --host --port`, **nextjs** runs `npm run dev` with `PORT` set. Runs `npm install` automatically if `node_modules` is missing. A project's optional `start` config block runs first — `gitPull` fast-forwards the checkout and `env` sets process environment variables. Two more opt-in conveniences: if the project has a `motd/` folder of `.txt` files, one is shown (rotating) at startup; if it has `scripts/build_version_tool.py`, the build number is bumped on each start. +Starts each project's dev server using the handler for its `kind`: **python** runs `python -m ` — or, for an ASGI/FastAPI app, `uvicorn ` (e.g. `app.main:app`) with the dev port and `--reload` — preferring the project's `.venv`; **vite** runs `npm run dev -- --host --port`, **nextjs** runs `npm run dev` with `PORT` set. Runs `npm install` automatically if `node_modules` is missing. A project's optional `start` config block runs first — `gitPull` fast-forwards the checkout and `env` sets process environment variables. Two more opt-in conveniences: if the project has a `motd/` folder of `.txt` files, one is shown (rotating) at startup; if it has `scripts/build_version_tool.py`, the build number is bumped on each start. ```powershell zstart viteapp # dev server on its configured port diff --git a/bash/zconfig.example.json b/bash/zconfig.example.json index d0f018b..dcc28da 100644 --- a/bash/zconfig.example.json +++ b/bash/zconfig.example.json @@ -29,6 +29,7 @@ "kind": "python", "localRoot": "/home/youruser/code/pyapp", "startModule": "pyapp.main", + "_startApp_note": "ASGI/FastAPI app? Use \"startApp\": \"app.main:app\" instead of startModule (runs uvicorn --port --reload).", "ports": { "dev": 8080 }, "domain": "pyapp.yourdomain.com", "start": { diff --git a/bash/zstart b/bash/zstart index 51f6f0b..a3b540b 100644 --- a/bash/zstart +++ b/bash/zstart @@ -8,8 +8,9 @@ # Usage: # zstart [ ...] [--port N] [--bind-host host] [--detached] # -# Handlers by kind: python (python -m , prefers .venv), -# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT). +# Handlers by kind: python (python -m , or uvicorn for +# ASGI/FastAPI apps; prefers .venv), vite (npm run dev -- --host --port), +# nextjs (npm run dev with PORT). # Detached servers log to /tmp/zstart-.log ; stop them with zkill. set -uo pipefail @@ -43,7 +44,13 @@ warn_if_privileged_port() { # detach() { # local key="$1" dir="$2"; shift 2 local log="/tmp/zstart-${key}.log" - ( cd "$dir" && nohup "$@" >"$log" 2>&1 & ) + # Sever every fd tied to this shell before exec'ing the long-lived server: + # stdin<-/dev/null, stdout/stderr->log, and CLOSE the ztokens tracking fds + # (3/4 = saved stdout/stderr; 1/2 fed the capture FIFO). If the server keeps + # any open, the parent's EXIT-trap footer (a `tee` on the FIFO) never gets + # EOF and `zstart --detached` hangs instead of returning. + ( cd "$dir" && exec "$log" 2>&1 3>&- 4>&-; exec nohup "$@" ) & + disown 2>/dev/null || true ok "Started in detached mode (logs: $log)." dim "Use zkill $key to stop it." } @@ -59,19 +66,22 @@ start_prep() { # dim " env $name=$val" done < <(jq -r --arg k "$key" '.projects[$k].start.env // {} | to_entries[] | "\(.key)\t\(.value)"' "$ZCONFIG") if [ "$(zproj "$key" .start.gitPull)" = "true" ] && [ -d "$root/.git" ]; then - local last - last="$( (cd "$root" && git pull --ff-only 2>&1) | tail -1 )" - dim " git pull: $last" - (cd "$root" && git rev-parse HEAD >/dev/null 2>&1) || warn " Auto-pull failed - run 'git pull' manually if needed." + # GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast instead + # of blocking the server start on an interactive "Username for ..." prompt. + local out rc + out="$( (cd "$root" && GIT_TERMINAL_PROMPT=0 git pull --ff-only 2>&1) )"; rc=$? + dim " git pull: $(printf '%s\n' "$out" | tail -1)" + [ "$rc" -eq 0 ] || warn " Auto-pull skipped (exit $rc) - starting with the current checkout. (git needs credentials here, or set start.gitPull=false)" fi } start_python() { # - local key="$1" port="$2" root module exe bv="" + local key="$1" port="$2" root module app exe bv="" run_cmd=() what using_venv=1 root="$(zproj_root "$key")" module="$(zproj "$key" .startModule)" + app="$(zproj "$key" .startApp)" [ -d "$root" ] || { err "Project root not found: $root"; return 1; } - [ -n "$module" ] || { err "Project '$key' (kind=python) needs 'startModule' in zconfig.json (e.g. \"startModule\": \"pyapp.main\")."; return 1; } + [ -n "$module" ] || [ -n "$app" ] || { err "Project '$key' (kind=python) needs 'startModule' (python -m ...) or 'startApp' (uvicorn app:app) in zconfig.json."; return 1; } # Prefer the project venv. The Scripts/python.exe branch is a *Windows* venv # layout — only runnable from a Windows-family shell (Git Bash/MSYS/Cygwin). @@ -79,8 +89,8 @@ start_python() { # # executable but can't exec, so guard that branch to fall through to python3. if [ -x "$root/.venv/bin/python" ]; then exe="$root/.venv/bin/python" elif [ -x "$root/.venv/Scripts/python.exe" ] && [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then exe="$root/.venv/Scripts/python.exe" - elif command -v python3 >/dev/null 2>&1; then exe="python3" - else exe="python"; fi + elif command -v python3 >/dev/null 2>&1; then exe="python3"; using_venv=0 + else exe="python"; using_venv=0; fi # Optional convention: scripts/build_version_tool.py bumps the version on dev start. if [ -f "$root/scripts/build_version_tool.py" ]; then @@ -88,8 +98,25 @@ start_python() { # [ -n "$bv" ] || bv="$(cd "$root" && "$exe" scripts/build_version_tool.py get 2>/dev/null | tail -1)" fi + # startApp (uvicorn ASGI target, e.g. app.main:app) takes precedence over + # startModule (python -m ...). uvicorn is run via the venv python's -m so no + # PATH juggling is needed, and it gets zstart's bind-host + dev port. + if [ -n "$app" ]; then + run_cmd=("$exe" -m uvicorn "$app" --host "$bind_host" --port "$port" --reload) + what="uvicorn $app" + else + run_cmd=("$exe" -m "$module") + what="python -m $module" + fi + printf '\n'; info "=== zstart ($(zproj "$key" .label)) ===" - info "Starting python -m $module on port $port..." + # No project venv found - warn, since the system interpreter usually lacks the + # app's deps (the failure would otherwise be a cryptic ModuleNotFoundError). + if [ "$using_venv" -eq 0 ]; then + warn "No project venv at $root/.venv - using system '$exe' (its deps may be missing)." + dim " Create one: (cd \"$root\" && python3 -m venv .venv && .venv/bin/pip install -e .)" + fi + info "Starting $what on port $port..." [ -n "$bv" ] && note "Build Version: $bv" show_project_motd "$root" dim "Press Ctrl+C to stop the server" @@ -97,9 +124,9 @@ start_python() { # printf '\n' if [ "$detached" -eq 1 ]; then - detach "$key" "$root" "$exe" -m "$module" + detach "$key" "$root" "${run_cmd[@]}" else - ( cd "$root" && exec "$exe" -m "$module" ) + ( cd "$root" && exec "${run_cmd[@]}" ) fi } diff --git a/zconfig.example.json b/zconfig.example.json index bfa96cd..d8f090d 100644 --- a/zconfig.example.json +++ b/zconfig.example.json @@ -24,6 +24,7 @@ "kind": "python", "localRoot": "C:\\YourRoot\\pyapp", "startModule": "pyapp.main", + "_startApp_note": "ASGI/FastAPI app? Use \"startApp\": \"app.main:app\" instead of startModule (runs uvicorn --port --reload).", "ports": { "dev": 8080 }, "domain": "pyapp.yourdomain.com", "start": { diff --git a/zstart.ps1 b/zstart.ps1 index 5a8ff7c..363d23d 100644 --- a/zstart.ps1 +++ b/zstart.ps1 @@ -12,8 +12,9 @@ # zstart viteapp -Port 3000 # zstart pyapp nextapp -Detached # -# Handlers by kind: python (python -m , prefers .venv), -# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT). +# Handlers by kind: python (python -m , or uvicorn for +# ASGI/FastAPI apps; prefers .venv), vite (npm run dev -- --host --port), +# nextjs (npm run dev with PORT). # param( [Parameter(Position = 0, ValueFromRemainingArguments = $true)] @@ -45,16 +46,18 @@ function Test-PortNeedsAdmin { } function Start-PythonProject { - param([string]$Key, $Proj, [int]$ListenPort, [bool]$RunDetached) + param([string]$Key, $Proj, [int]$ListenPort, [bool]$RunDetached, [string]$HostBind = "127.0.0.1") $root = $Proj.localRoot if (-not (Test-Path -LiteralPath $root)) { throw "Project root not found: $root" } $module = $Proj.startModule - if (-not $module) { - throw "Project '$Key' (kind=python) needs 'startModule' in zconfig.json (e.g. `"startModule`": `"pyapp.main`" runs 'python -m pyapp.main')." + $app = $Proj.startApp + if (-not $module -and -not $app) { + throw "Project '$Key' (kind=python) needs 'startModule' (python -m ...) or 'startApp' (uvicorn app:app) in zconfig.json." } Set-Location -LiteralPath $root $venvPython = Join-Path $root ".venv\Scripts\python.exe" - $exe = if (Test-Path -LiteralPath $venvPython) { $venvPython } else { "python" } + $usingVenv = Test-Path -LiteralPath $venvPython + $exe = if ($usingVenv) { $venvPython } else { "python" } # Optional convention: if the project ships scripts/build_version_tool.py, # bump (or at least read) the build version on every dev start. @@ -77,9 +80,24 @@ function Start-PythonProject { } } + # startApp (uvicorn ASGI target, e.g. app.main:app) takes precedence over + # startModule (python -m ...). uvicorn runs via the venv python's -m, and + # gets zstart's bind-host + dev port. + if ($app) { + $runArgs = @("-m", "uvicorn", $app, "--host", $HostBind, "--port", "$ListenPort", "--reload") + $what = "uvicorn $app" + } else { + $runArgs = @("-m", $module) + $what = "python -m $module" + } + Write-Host "" Write-Host "=== zstart ($($Proj.label)) ===" -ForegroundColor Cyan - Write-Host "Starting python -m $module on port $ListenPort..." -ForegroundColor Cyan + if (-not $usingVenv) { + Write-Host "No project venv at $root\.venv - using system 'python' (its deps may be missing)." -ForegroundColor Yellow + Write-Host " Create one: python -m venv .venv; .\.venv\Scripts\pip install -e ." -ForegroundColor DarkGray + } + Write-Host "Starting $what on port $ListenPort..." -ForegroundColor Cyan if (-not [string]::IsNullOrWhiteSpace($buildVersion)) { Write-Host "Build Version: $buildVersion" -ForegroundColor Magenta } @@ -91,11 +109,11 @@ function Start-PythonProject { Write-Host "" if ($RunDetached) { - Start-Process -FilePath $exe -ArgumentList "-m", $module -WorkingDirectory $root | Out-Null + Start-Process -FilePath $exe -ArgumentList $runArgs -WorkingDirectory $root | Out-Null Write-Host "Started in detached mode." -ForegroundColor Green Write-Host "Use zkill $Key to stop it." -ForegroundColor DarkGray } else { - & $exe -m $module + & $exe @runArgs } } @@ -181,14 +199,18 @@ function Invoke-ProjectStartPrep { } if ($Proj.start.gitPull -and (Test-Path (Join-Path $Proj.localRoot ".git"))) { Push-Location -LiteralPath $Proj.localRoot + # GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast + # instead of blocking the server start on a "Username for ..." prompt. + $prev = $env:GIT_TERMINAL_PROMPT; $env:GIT_TERMINAL_PROMPT = "0" try { $pullOut = git pull --ff-only 2>&1 $last = ($pullOut | Select-Object -Last 1) Write-Host " git pull: $last" -ForegroundColor DarkGray if ($LASTEXITCODE -ne 0) { - Write-Host " Auto-pull failed - run 'git pull' manually if needed." -ForegroundColor Yellow + Write-Host " Auto-pull skipped - starting with the current checkout. (git needs credentials here, or set start.gitPull=false)" -ForegroundColor Yellow } } finally { + $env:GIT_TERMINAL_PROMPT = $prev Pop-Location } } @@ -201,7 +223,7 @@ foreach ($key in $Projects) { Invoke-ProjectStartPrep -Proj $proj switch ([string]$proj.kind) { - "python" { Start-PythonProject -Key $key -Proj $proj -ListenPort $devPort -RunDetached $Detached.IsPresent } + "python" { Start-PythonProject -Key $key -Proj $proj -ListenPort $devPort -HostBind $BindHost -RunDetached $Detached.IsPresent } "vite" { Start-ViteProject -Key $key -Proj $proj -ListenPort $devPort -HostBind $BindHost -RunDetached $Detached.IsPresent } "nextjs" { Start-NextProject -Key $key -Proj $proj -ListenPort $devPort -RunDetached $Detached.IsPresent } default { From 4d086bafae8cdbb06c5b7bee4399a3d44860a080 Mon Sep 17 00:00:00 2001 From: kellymichels Date: Sat, 25 Jul 2026 22:17:43 -0500 Subject: [PATCH 6/6] feat(zec2_rotatekeys): rotate/reset server-side secrets without exposing values (#24) * feat(zec2_rotatekeys): rotate/reset server-side secrets without exposing values New tool for the leaked/overwritten prod .env case: -Rotate KEY regenerates a key ON THE SERVER (openssl rand -hex 32) so the value never leaves the box; -Set KEY takes an operator-known value from a masked prompt and streams it over SSH stdin (never a command arg, never echoed). Backs the server .env up to a timestamped .bak first, updates keys atomically (match-or-append), auto-detects backend/.env from deploy.preserve, restarts only with -Restart, and -WhatIf previews the plan. Docs added to README + CHANGELOG. * fix(zec2_rotatekeys): recreate container on -Restart so the new .env loads A plain 'docker compose restart' reuses the container's existing environment and would NOT pick up env_file changes, leaving the app on the old secrets after a rotation. -Restart now runs 'up -d --force-recreate ', the reliable way to apply the new .env. Docs updated to match. --- CHANGELOG.md | 12 ++ README.md | 17 +++ zec2_rotatekeys.cmd | 6 + zec2_rotatekeys.ps1 | 288 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 zec2_rotatekeys.cmd create mode 100644 zec2_rotatekeys.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index fec7eef..aaa308f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,18 @@ Notable changes to the Evomedia.net Token Savers. credentials and RSA signing keys. ### Added +- **`zec2_rotatekeys` — safely rotate/reset server-side secrets** — a new + tool for when a secret leaks or a deploy overwrites a production `.env` + with dev values. `-Rotate KEY` regenerates a key **on the server** + (`openssl rand -hex 32`) so the new value never leaves the box; `-Set KEY` + takes an operator-known value (e.g. `DATABASE_URL`, `ADMIN_EMAIL`) from a + masked prompt and streams it over SSH stdin — never a command argument, + never echoed. Backs the server `.env` up to a timestamped `.bak` first, + updates the key atomically (matches or appends), auto-detects + `backend/.env` from `deploy.preserve`, and with `-Restart` **recreates** + the container (`up -d --force-recreate`, so the new values actually load — + a plain restart keeps the old environment). `-WhatIf` previews the plan + without touching anything. - **`zkill all`** — `zkill` now accepts `all`, stopping the dev server of every project that has a `ports.dev` (edge/docker stacks with no local dev server are skipped). Brings it in line with `zdeploy all` / `zbackup all`; diff --git a/README.md b/README.md index b667b1b..aa33ea2 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more | `zec2 [ ...]` | Quick reachability check (TCP + HTTP + live build version) | | `zec2online [ ...]` | Deep health check; auto-starts downed stacks, streams diagnostics | | `zrepair ...` | Audit + repair compose/proxy state on the server | +| `zec2_rotatekeys ` | Rotate/reset secret keys in a project's server-side `.env` (values generated server-side; never printed) | | `zbackup ... \| all` | Zip local project sources (+ DB dump) to the backups folder | | `zbackup_ec2 [ ...]` | Pull DB dumps + server-side data files down from the server | | `zsync []` | Copy new backups offsite (or build + mirror a vite dist) | @@ -223,6 +224,22 @@ zstop [ ...] `docker compose down` for the selected stacks on the server. Data volumes are preserved; `zdeploy ` brings a stack back. (PowerShell script only, no `.cmd` wrapper.) +#### `zec2_rotatekeys` — rotate server-side secrets + +``` +zec2_rotatekeys [-Rotate KEY,KEY] [-Set KEY,KEY] [-EnvFile rel/path] [-Restart] [-WhatIf] +``` + +For when a secret leaks or a deploy overwrites a production `.env` with dev values: rotate or reset keys in a project's **server-side** `.env` without the values ever passing through this machine's shell history, a command argument, or your screen. `-Rotate` keys are regenerated **on the server** with `openssl rand -hex 32` — the new value is written straight into the `.env` there and never leaves the box. `-Set` keys are typed into a masked prompt and streamed to the server over SSH stdin (never a command argument, never echoed), for operator-known values like `DATABASE_URL` or `ADMIN_EMAIL`. The current server `.env` is copied to a timestamped `.bak` before any change; the KEY line is updated atomically, matching an existing key or appending it. The env file is auto-detected from the project's `deploy.preserve` (first `*.env`) or defaults to `.env` — override with `-EnvFile backend/.env`. Nothing touches the running app unless you pass `-Restart`, which **recreates** the container (`docker compose up -d --force-recreate `) so it actually reloads the new `.env` — a plain `restart` would keep the old environment. Being high-impact, it confirms before writing; `-WhatIf` prints the exact plan and changes nothing. + +```powershell +# Preview only — see exactly what would change, change nothing: +zec2_rotatekeys pyapp -Rotate JWT_SECRET -Set DATABASE_URL,ADMIN_EMAIL -WhatIf + +# Regenerate the JWT secret, restore the operator-known values, then restart: +zec2_rotatekeys pyapp -Rotate JWT_SECRET -Set DATABASE_URL,ADMIN_EMAIL,ADMIN_PASSWORD -Restart +``` + ### Backups #### `zbackup` — local backups diff --git a/zec2_rotatekeys.cmd b/zec2_rotatekeys.cmd new file mode 100644 index 0000000..4354967 --- /dev/null +++ b/zec2_rotatekeys.cmd @@ -0,0 +1,6 @@ +REM Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers +REM Created by Kelly Michels · dev@evomedia.net +REM Licensed under the MIT License. See LICENSE. + +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0zec2_rotatekeys.ps1" %* diff --git a/zec2_rotatekeys.ps1 b/zec2_rotatekeys.ps1 new file mode 100644 index 0000000..a4dbc7e --- /dev/null +++ b/zec2_rotatekeys.ps1 @@ -0,0 +1,288 @@ +# 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. + +# zec2_rotatekeys.ps1 - rotate / reset secret keys in a project's SERVER-SIDE +# .env, in place, without the values ever passing through this machine's shell +# history, command args, or the operator's screen. +# +# Why this exists: if a deploy ever ships a dev .env over a project's production +# .env (or a secret leaks), you need to (1) regenerate the machine secrets and +# (2) restore the correct operator-known values on the server - safely. +# +# How it stays safe: +# * -Rotate keys are regenerated ON THE SERVER (openssl rand -hex 32). The new +# value is created on the box and written straight into the .env there; it is +# never sent from here, never printed. +# * -Set keys are typed into a masked prompt and streamed to the server over +# SSH stdin (the encrypted channel) - never placed in a command argument +# (where `ps`/history would capture it) and never echoed back. +# * The current server .env is copied to a timestamped .bak before any change. +# * -WhatIf prints the exact plan and touches nothing. High-impact, so it +# confirms before writing unless you pass -Confirm:$false. +# * It does NOT touch the running app unless you pass -Restart, which +# recreates the container (up -d --force-recreate) so it reloads the new +# .env - a plain `restart` reuses the old environment. Prod restarts are a +# deliberate, separate decision. +# +# Usage: +# zec2_rotatekeys [-Rotate K1,K2] [-Set K1,K2] [-EnvFile rel/path] +# [-Restart] [-HostName ip] [-WhatIf] [-Confirm:$false] +# +# Examples: +# # Preview only - see exactly what would change, change nothing: +# zec2_rotatekeys pyapp -Rotate JWT_SECRET -Set DATABASE_URL,ADMIN_EMAIL -WhatIf +# +# # Regenerate the JWT secret and restore the operator-known values, then +# # restart the app so it picks them up: +# zec2_rotatekeys pyapp -Rotate JWT_SECRET -Set DATABASE_URL,ADMIN_EMAIL,ADMIN_PASSWORD -Restart +# +# The env file is auto-detected from the project's deploy.preserve (first *.env +# entry) or defaults to ".env"; override with -EnvFile (relative to remote.path, +# e.g. -EnvFile backend/.env). + +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Position = 0)][string]$Project, + [string[]]$Rotate = @(), + [string[]]$Set = @(), + [string]$EnvFile, + [switch]$Restart, + [string]$HostName +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "ZHelpers.ps1") +Start-ZTracking + +function Show-Usage { + Write-Host "" + Write-Host "Usage: zec2_rotatekeys [-Rotate K1,K2] [-Set K1,K2] [-EnvFile rel/path] [-Restart] [-WhatIf]" -ForegroundColor Yellow + Write-Host " -Rotate keys regenerated on the server with a fresh random secret (openssl rand -hex 32)" -ForegroundColor Gray + Write-Host " -Set keys set from a masked prompt, streamed over SSH stdin (for operator-known values)" -ForegroundColor Gray + try { $keys = (Get-ZProjectKeys) -join ', '; Write-Host " Projects: $keys" -ForegroundColor Gray } catch { } + Write-Host "" + Write-Host " Example: zec2_rotatekeys pyapp -Rotate JWT_SECRET -Set DATABASE_URL,ADMIN_EMAIL,ADMIN_PASSWORD -Restart" -ForegroundColor DarkGray +} + +# Server-side worker. Static (no secrets): -Rotate generates its value here on +# the box; -Set reads it from stdin. Uploaded base64-encoded so line endings and +# quoting survive the trip intact. Updates the KEY line atomically via python. +$rkHelper = @' +#!/usr/bin/env bash +set -uo pipefail +env_file="${1:-}"; key="${2:-}"; mode="${3:-}" +if [ -z "$env_file" ] || [ -z "$key" ] || [ -z "$mode" ]; then echo "BAD_ARGS"; exit 5; fi +if [ ! -f "$env_file" ]; then echo "ENV_MISSING:$env_file"; exit 2; fi +case "$mode" in + rotate) + command -v openssl >/dev/null 2>&1 || { echo "NO_OPENSSL"; exit 6; } + val="$(openssl rand -hex 32)" + ;; + set) + IFS= read -r val || true + val="${val%$'\r'}" + if [ -z "$val" ]; then echo "EMPTY_VALUE:$key"; exit 4; fi + ;; + *) echo "BAD_MODE:$mode"; exit 3 ;; +esac +KEY="$key" VAL="$val" python3 - "$env_file" <<'PY' +import os, sys, tempfile +path = sys.argv[1] +k = os.environ['KEY']; v = os.environ['VAL'] +with open(path, 'r') as fh: + lines = fh.read().splitlines() +out = [] +found = False +for ln in lines: + s = ln.lstrip() + if (not s.startswith('#')) and ('=' in ln) and (ln.split('=', 1)[0].strip() == k): + out.append(k + '=' + v) + found = True + else: + out.append(ln) +if not found: + out.append(k + '=' + v) +d = os.path.dirname(path) or '.' +fd, tmp = tempfile.mkstemp(dir=d) +try: + with os.fdopen(fd, 'w') as fh: + fh.write('\n'.join(out) + '\n') + os.chmod(tmp, 0o600) + os.replace(tmp, path) +except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise +print('OK:' + k) +PY +'@ + +try { + if (-not $Project) { Show-Usage; Stop-ZTracking; exit 1 } + + $Rotate = @($Rotate | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) + $Set = @($Set | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) + + if ($Rotate.Count -eq 0 -and $Set.Count -eq 0) { + Write-Host "ERROR: nothing to do - pass -Rotate and/or -Set with at least one key." -ForegroundColor Red + Show-Usage; Stop-ZTracking; exit 1 + } + + # A key can't be both regenerated and set - -Set (explicit value) wins. + $overlap = @($Rotate | Where-Object { $Set -contains $_ }) + if ($overlap.Count -gt 0) { + Write-Host "NOTE: $($overlap -join ', ') given to both -Rotate and -Set; using -Set (explicit value)." -ForegroundColor Yellow + $Rotate = @($Rotate | Where-Object { $Set -notcontains $_ }) + } + + $cfg = Get-ZConfig + $proj = Get-ZProject -Key $Project # exits with a clear error on a bad key + $remotePath = $proj.remote.path + if (-not $remotePath) { + Write-Host "ERROR: project '$Project' has no remote.path in zconfig.json - nothing to rotate on the server." -ForegroundColor Red + Stop-ZTracking; exit 1 + } + + # Env file location: -EnvFile wins; else first *.env in deploy.preserve; else ".env". + if (-not $EnvFile) { + $EnvFile = ".env" + if ($proj.deploy -and $proj.deploy.preserve) { + $cand = @($proj.deploy.preserve | Where-Object { $_ -match '\.env$' }) | Select-Object -First 1 + if ($cand) { $EnvFile = $cand } + } + } + $EnvFile = $EnvFile -replace '\\', '/' + $remoteEnv = "$remotePath/$EnvFile" + + $ip = if ($HostName) { $HostName } else { $cfg.ec2.ip } + $pem = $cfg.ec2.pemKey + $target = "$($cfg.ec2.user)@$ip" + $sshOpts = @('-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=15', '-i', $pem) + $remoteHelper = "/tmp/zrk_$PID.sh" + + Write-Host "" + Write-Host "=== zec2_rotatekeys ($($proj.label)) ===" -ForegroundColor Cyan + Write-Host " Server: $target" -ForegroundColor DarkGray + Write-Host " Env file: $remoteEnv" -ForegroundColor DarkGray + if ($Rotate.Count) { Write-Host " Rotate (fresh random, server-side): $($Rotate -join ', ')" -ForegroundColor Gray } + if ($Set.Count) { Write-Host " Set (masked prompt, streamed): $($Set -join ', ')" -ForegroundColor Gray } + if ($Restart) { Write-Host " Then: recreate the app container (reloads the new .env)" -ForegroundColor Gray } + Write-Host "" + + $action = @() + if ($Rotate.Count) { $action += "rotate [$($Rotate -join ',')]" } + if ($Set.Count) { $action += "set [$($Set -join ',')]" } + if ($Restart) { $action += "recreate" } + if (-not $PSCmdlet.ShouldProcess("${target}:$remoteEnv", ($action -join ' + '))) { + Write-Host "Preview only - no changes made." -ForegroundColor Yellow + Stop-ZTracking; exit 0 + } + + # --- confirm the env file actually exists before we touch anything -------- + $exists = (ssh @sshOpts $target "test -f $remoteEnv && echo EXISTS || echo MISSING") | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { throw "Could not reach $target over SSH (exit $LASTEXITCODE)." } + if ($exists -ne "EXISTS") { + throw "Env file not found on the server: $remoteEnv. Check remote.path / -EnvFile." + } + + # --- back up the current server .env -------------------------------------- + $ts = Get-Date -Format "yyyyMMdd-HHmmss" + $backup = "$remoteEnv.bak.$ts" + ssh @sshOpts $target "cp -p $remoteEnv $backup" + if ($LASTEXITCODE -ne 0) { throw "Backup of $remoteEnv failed (exit $LASTEXITCODE) - aborting before any change." } + Write-Host " Backed up server .env -> $backup" -ForegroundColor DarkGray + + # --- upload the worker (base64: no CR / quoting surprises) ----------------- + $helperLf = $rkHelper -replace "`r`n", "`n" + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($helperLf)) + ssh @sshOpts $target "echo $b64 | base64 -d > $remoteHelper && chmod 700 $remoteHelper" + if ($LASTEXITCODE -ne 0) { throw "Failed to stage the rotation helper on the server (exit $LASTEXITCODE)." } + + $done = @() + $failed = @() + try { + # --- rotate: value generated on the server, never seen here ----------- + foreach ($key in $Rotate) { + $r = (ssh @sshOpts $target "bash $remoteHelper $remoteEnv $key rotate") | Select-Object -Last 1 + if ($LASTEXITCODE -eq 0 -and $r -like "OK:*") { + Write-Host " rotated $key" -ForegroundColor Green + $done += "$key (rotated)" + } else { + Write-Host " FAILED $key ($r)" -ForegroundColor Red + $failed += "$key ($r)" + } + } + + # --- set: masked prompt -> SSH stdin, never in args or on screen ------ + foreach ($key in $Set) { + $secure = Read-Host -Prompt " New value for $key" -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + try { + $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + if ([string]::IsNullOrEmpty($plain)) { + Write-Host " skipped $key (no value entered)" -ForegroundColor Yellow + $failed += "$key (empty - skipped)" + $plain = $null + continue + } + $r = ($plain | ssh @sshOpts $target "bash $remoteHelper $remoteEnv $key set") | Select-Object -Last 1 + $rc = $LASTEXITCODE + $plain = $null # drop the plaintext from memory promptly + if ($rc -eq 0 -and $r -like "OK:*") { + Write-Host " set $key" -ForegroundColor Green + $done += "$key (set)" + } else { + Write-Host " FAILED $key ($r)" -ForegroundColor Red + $failed += "$key ($r)" + } + } + } finally { + ssh @sshOpts $target "rm -f $remoteHelper" | Out-Null + } + + # --- optional app recreate ------------------------------------------------ + # A plain `docker compose restart` reuses the container's existing + # environment, so it would NOT pick up the .env we just edited. `up -d + # --force-recreate` rebuilds the container from current config, reloading + # env_file / environment - the reliable way to apply the new secrets. + $restarted = $false + if ($Restart -and $done.Count -gt 0) { + $composeDir = if ($proj.remote.composeDir) { $proj.remote.composeDir } else { $remotePath } + $svc = if ($proj.remote.appService) { $proj.remote.appService } else { "app" } + Write-Host "" + Write-Host " Recreating service '$svc' in $composeDir (to load the new .env) ..." -ForegroundColor Cyan + ssh @sshOpts $target "cd $composeDir && sudo COMPOSE_BAKE=false docker compose up -d --force-recreate $svc" + if ($LASTEXITCODE -eq 0) { Write-Host " Recreated $svc." -ForegroundColor Green; $restarted = $true } + else { Write-Host " WARNING: recreate of '$svc' failed (exit $LASTEXITCODE) - apply it manually: docker compose up -d --force-recreate $svc" -ForegroundColor Red } + } elseif ($Restart) { + Write-Host " Skipping recreate - no keys were changed." -ForegroundColor Yellow + } + + # --- summary -------------------------------------------------------------- + Write-Host "" + Write-Host "=== Summary ===" -ForegroundColor Cyan + Write-Host " Changed: $(if ($done.Count) { $done -join ', ' } else { 'none' })" -ForegroundColor $(if ($done.Count) { 'Green' } else { 'Yellow' }) + if ($failed.Count) { Write-Host " Failed: $($failed -join ', ')" -ForegroundColor Red } + Write-Host " Backup: $backup (delete once verified)" -ForegroundColor DarkGray + if ($Restart -and -not $restarted -and $done.Count -gt 0) { + Write-Host " Recreate: NOT done - apply the new values with: docker compose up -d --force-recreate " -ForegroundColor Yellow + } elseif (-not $Restart -and $done.Count -gt 0) { + Write-Host " Note: the app is still running with the OLD values - re-run with -Restart, or 'docker compose up -d --force-recreate ' on the server." -ForegroundColor Yellow + } + Write-Host "" + + Stop-ZTracking + if ($failed.Count) { exit 2 } + exit 0 +} +catch { + Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red + Stop-ZTracking + exit 1 +}