merge master into feat/zsetup-command, keeping both zsetup and startApp keys

Five PRs (#11, #23, #10, #12, #24) landed on master since this branch opened.
Conflicts were all additive and in the same spots this PR documents its new
'install' key: README's config reference and both zconfig.example.json files,
where #12 had added the startApp note on the adjacent line. Resolved by keeping
both keys, with the startApp note next to startModule (it documents that key)
and install after it. Nothing dropped from either side.
This commit is contained in:
KellyMichels 2026-07-25 22:21:21 -05:00
commit d34dfd535a
22 changed files with 588 additions and 73 deletions

View File

@ -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`;
@ -49,6 +61,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

View File

@ -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 <dev> --reload
"install": "-e .", // optional: pip args `zsetup` uses (auto-detects "-e ." / "-r requirements.txt")
"ports": { "dev": 8080, "prod": 3000 }, // local dev port / direct server port
"domain": "www.myapp.com", // public domain (health checks + verification)
@ -125,6 +126,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more
| `zec2 [<key> ...]` | Quick reachability check (TCP + HTTP + live build version) |
| `zec2online [<key> ...]` | Deep health check; auto-starts downed stacks, streams diagnostics |
| `zrepair <key> ...` | Audit + repair compose/proxy state on the server |
| `zec2_rotatekeys <key>` | Rotate/reset secret keys in a project's server-side `.env` (values generated server-side; never printed) |
| `zbackup <key> ... \| all` | Zip local project sources (+ DB dump) to the backups folder |
| `zbackup_ec2 [<key> ...]` | Pull DB dumps + server-side data files down from the server |
| `zsync [<key>]` | Copy new backups offsite (or build + mirror a vite dist) |
@ -138,7 +140,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more
zstart <project> [<project> ...] [-Port N] [-BindHost <host>] [-Detached]
```
Starts each project's dev server using the handler for its `kind`: **python** runs `python -m <startModule>` (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 <startModule>` — or, for an ASGI/FastAPI app, `uvicorn <startApp>` (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
@ -225,6 +227,22 @@ zstop <project> [<project> ...]
`docker compose down` for the selected stacks on the server. Data volumes are preserved; `zdeploy <project>` brings a stack back. (PowerShell script only, no `.cmd` wrapper.)
#### `zec2_rotatekeys` — rotate server-side secrets
```
zec2_rotatekeys <project> [-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 <svc>`) 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

View File

@ -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" }
@ -177,10 +186,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 { @() }
}

View File

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

View File

@ -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 <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"
#
@ -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 <project> [<project> ...] | 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 <root>/.env, then <root>/backend/.env. Returns 0 and writes $2 on success.
# Checks <root>/.env, then <root>/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() { # <root> <out_path>
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() { # <key>
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=()

View File

@ -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 <project> [<project> ...]
# 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 <project> [<project> ...] | 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')"

View File

@ -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 <project> [<project> ...]
# zbackup_ec2 all # every project with a remote.path
#
# Output:
# <paths.backupsEc2>/<project>/<timestamp>_<project>_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 <project> [<project> ...] | 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

View File

@ -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 <dev> --reload).",
"install": "-e .",
"ports": { "dev": 8080 },
"domain": "pyapp.yourdomain.com",

View File

@ -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 <project> [<project> ...]
# 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 <project> [<project> ...] | all [--host <ip>]"
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."

View File

@ -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 <project> [<project> ...]
# 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 <project> [<project> ...] | all [--host <ip>]"
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."

View File

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

View File

@ -8,8 +8,9 @@
# Usage:
# zstart <project> [<project> ...] [--port N] [--bind-host host] [--detached]
#
# Handlers by kind: python (python -m <startModule>, prefers .venv),
# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT).
# Handlers by kind: python (python -m <startModule>, or uvicorn <startApp> for
# ASGI/FastAPI apps; prefers .venv), vite (npm run dev -- --host --port),
# nextjs (npm run dev with PORT).
# Detached servers log to /tmp/zstart-<project>.log ; stop them with zkill.
set -uo pipefail
@ -43,7 +44,13 @@ warn_if_privileged_port() { # <port>
detach() { # <key> <workdir> <cmd...>
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 </dev/null >"$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() { # <key>
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() { # <key> <port>
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() { # <key> <port>
# 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() { # <key> <port>
[ -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() { # <key> <port>
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
}

View File

@ -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 `

View File

@ -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 <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"
#
@ -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 <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') {
@ -53,19 +67,29 @@ function Ensure-BackupDir {
# 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)) { 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://(?<user>[^:]+):(?<pass>[^@]+)@(?<host>[^:]+):(?<port>\d+)/(?<db>[^?]+)'
'^postgresql://(?<user>[^:@/]+):(?<pass>[^@]+)@(?<host>[^:/?]+)(:(?<port>\d+))?/(?<db>[^?\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

View File

@ -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 <project> [<project> ...]
# zbackup_and_sync.ps1 all # backup everything + sync
#
# Scheduled Task example (see setup_backup_schedule.ps1):
# powershell -ExecutionPolicy Bypass -NoProfile -File "<scriptsRoot>\zbackup_and_sync.ps1"
# powershell -ExecutionPolicy Bypass -NoProfile -File "<scriptsRoot>\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 <project> [<project> ...] | 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) {

View File

@ -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 <project> [<project> ...]
# zbackup_ec2 all # every project with a remote.path
#
# Output:
# <paths.backupsEc2>\<project>\<timestamp>_<project>_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 <project> [<project> ...] | 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 })
}

View File

@ -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 <dev> --reload).",
"install": "-e .",
"ports": { "dev": 8080 },
"domain": "pyapp.yourdomain.com",

View File

@ -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 <project> [<project> ...]
# 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 <project> [<project> ...] | all [-HostName <ip>]" -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

6
zec2_rotatekeys.cmd Normal file
View File

@ -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" %*

288
zec2_rotatekeys.ps1 Normal file
View File

@ -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 <project> [-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 <project> [-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 <svc>" -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 <svc>' 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
}

View File

@ -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 <project> [<project> ...]
# 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 <project> [<project> ...] | all [-HostName <ip>]" -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

View File

@ -12,8 +12,9 @@
# zstart viteapp -Port 3000
# zstart pyapp nextapp -Detached
#
# Handlers by kind: python (python -m <startModule>, prefers .venv),
# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT).
# Handlers by kind: python (python -m <startModule>, or uvicorn <startApp> 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 {