feat(bash): native bash port of all z-scripts for Linux/macOS/WSL

Full port: zhelpers.sh library (jq config, ssh, http/tcp, archive builder,
build-version, motd, port-kill), all commands (zstart/zkill/zrestart/zstop,
zdeploy with 5 kind handlers, zec2/zec2online/zrepair, zbackup/zbackup_ec2/
zsync/zbackup_and_sync, zstart_docker, zsetup_mail, setup_backup_schedule via
cron), Unix-path zconfig.example.json, and a bash/README.md.

Verified: bash -n clean on all scripts; archive exclusions, config semantics,
and zec2 tested against the real config and live server from Git Bash. Needs a
Linux/macOS/WSL shakedown for lsof/rsync/nohup paths before merging.
This commit is contained in:
KellyMichels 2026-07-16 14:07:02 -05:00
parent d97b92f989
commit 8e6ffea1a4
18 changed files with 2110 additions and 0 deletions

62
bash/README.md Normal file
View File

@ -0,0 +1,62 @@
<!--
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.
-->
# Token Savers — bash port (Linux / macOS / WSL)
Native bash versions of the z-scripts. Same commands, same `zconfig.json` schema,
same behavior — no PowerShell required.
## Requirements
- bash 4+, `jq`, `curl`, `zip`, OpenSSH (`ssh`/`scp`)
- `lsof` (for `zkill`/`zrestart`), `rsync` (for `zsync <project>` mirror mode)
- Docker + docker compose on the remote host for the deploy scripts
Debian/Ubuntu: `sudo apt install jq curl zip lsof rsync`
macOS: `brew install jq` (the rest ships with the OS)
## Install
```bash
git clone https://github.com/kellymichels/zscripts-token-savers.git ~/zscripts
cd ~/zscripts/bash
cp zconfig.example.json zconfig.json # then fill in your values
chmod +x z* setup_backup_schedule
echo 'export PATH="$HOME/zscripts/bash:$PATH"' >> ~/.bashrc && source ~/.bashrc
```
## Commands
Same set as the PowerShell versions — the project key in `zconfig.json` IS the
command argument:
| Command | What it does |
|---|---|
| `zstart <p> [--detached]` | start local dev server (python/vite/nextjs) |
| `zkill <p>` | free the project's dev port |
| `zrestart <p> [--detached]` | kill + start in one command |
| `zstop <p>` | stop the project's compose stack on the server |
| `zdeploy <p> [--note "msg"]` | zip → scp → docker compose build/up → verify build version |
| `zec2 [<p>]` | TCP + HTTP + live-version reachability check |
| `zec2online [<p>]` | deep health check; auto-starts downed stacks |
| `zrepair <p>` | audit/repair container + proxy routing, smoke test |
| `zbackup [<p>] [--tag t]` | local source zip (+ pg_dump when .env has DATABASE_URL) |
| `zbackup_ec2 [<p>]` | pull db dump + data dirs down from the server |
| `zsync` | copy new backup files offsite (never overwrites) |
| `zbackup_and_sync` | both of the above; cron-friendly |
| `setup_backup_schedule --install` | daily backup cron job |
| `zsetup_mail --domain d` | provision docker-mailserver mailboxes + print DNS records |
| `zstart_docker` | bring up the local compose stack in `bash/docker/` |
Flags use GNU style (`--port 3000`, `--detached`) instead of PowerShell style
(`-Port`, `-Detached`). Detached dev servers log to `/tmp/zstart-<project>.log`.
## Differences from the PowerShell versions
- `zsync <viteproject>` mirrors `dist/` with `rsync -a --delete` (robocopy /MIR equivalent).
- Scheduling uses cron (`setup_backup_schedule`) instead of Windows Task Scheduler.
- The MOTD banner picks a random `motd/*.txt` instead of tracking a shuffle rotation.
- Windows-only helpers (`.cmd` launchers) don't exist — scripts are directly executable.

View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# 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.
#
# setup_backup_schedule — schedule daily zbackup_and_sync via cron.
# (The PowerShell version creates a Windows Scheduled Task; this is the cron equivalent.)
#
# Usage:
# setup_backup_schedule # print the crontab line to add
# setup_backup_schedule --install # append it to your crontab (2:00 AM daily)
# setup_backup_schedule --time 03:30 --install
set -uo pipefail
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_HERE/zhelpers.sh"
install=0; at_time="02:00"
while [ $# -gt 0 ]; do
case "$1" in
--install) install=1; shift ;;
--time) at_time="${2:-02:00}"; shift 2 ;;
*) err "Unknown option: $1"; exit 1 ;;
esac
done
hh="${at_time%%:*}"; mm="${at_time##*:}"
if ! [[ "$hh" =~ ^[0-9]{1,2}$ && "$mm" =~ ^[0-9]{2}$ ]] || [ "$hh" -gt 23 ] || [ "$mm" -gt 59 ]; then
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"
info "=== Backup schedule (cron) ==="
dim "Daily at $at_time:"
printf '\n %s\n\n' "$cron_line"
if [ "$install" -eq 1 ]; then
z_require crontab
if crontab -l 2>/dev/null | grep -qF "$_HERE/zbackup_and_sync"; then
warn "A crontab entry for zbackup_and_sync already exists - not adding a duplicate."
dim "Edit it with: crontab -e"
exit 0
fi
( crontab -l 2>/dev/null; printf '%s\n' "$cron_line" ) | crontab - \
|| { err "Failed to update crontab."; exit 1; }
ok "Installed. Verify with: crontab -l"
else
dim "Run with --install to add it to your crontab, or add it manually with: crontab -e"
fi

131
bash/zbackup Normal file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels · dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
#
# zbackup — local backups: zip project sources (plus a Postgres dump when the
# 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 scripts # just this scripts folder ('scripts' is a reserved word)
# zbackup pyapp --tag "pre-migration"
#
# Output: <paths.backupsLocal>/<project>/<timestamp>_<project>[_tag].zip
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require zip
projects=(); tag=""
while [ $# -gt 0 ]; do
case "$1" in
--tag) tag="${2:-}"; shift 2 ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
BACKUP_ROOT="$(zq '.paths.backupsLocal')"
include_scripts=0
if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(zproj_keys)
include_scripts=1
else
filtered=()
for p in "${projects[@]}"; do
if [ "$p" = "scripts" ]; then include_scripts=1; else filtered+=("$p"); fi
done
projects=("${filtered[@]+"${filtered[@]}"}")
fi
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.
local_pg_dump() { # <root> <out_path>
local root="$1" out="$2" env_file db_url
env_file="$root/.env"; [ -f "$env_file" ] || env_file="$root/backend/.env"
[ -f "$env_file" ] || return 1
db_url="$(grep -m1 '^DATABASE_URL=' "$env_file" | cut -d= -f2- | tr -d '[:space:]')"
[ -n "$db_url" ] || return 1
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
ok " PostgreSQL dump: $(awk -v b="$(wc -c < "$out")" 'BEGIN{printf "%.1f", b/1024}') KB"
return 0
fi
err " pg_dump failed"
return 1
fi
err ' Could not parse DATABASE_URL - skipping PG backup'
return 1
}
declare -a summary
exit_code=0
backup_project() { # <key>
local key="$1" root out_dir zip_path dump_dir db_dump extra="-"
zproj_require "$key"
root="$(zproj "$key" .localRoot)"
out_dir="$BACKUP_ROOT/$key"; mkdir -p "$out_dir"
zip_path="$out_dir/$(ts)_${key}$(tag_suffix).zip"
printf '\n'; info "=== [$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')] Local backup ($(zproj "$key" .label)) ==="
dim " Output: $zip_path"
[ -d "$root" ] || { err " Root not found: $root"; return 1; }
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
warn " [2/3] Archiving source..."
local excl=()
mapfile -t excl < <(z_archive_excludes "$key" 1)
if ! z_archive "$root" "$zip_path" 0 "$extra" "${excl[@]}"; then
rm -rf "$dump_dir"; return 1
fi
rm -rf "$dump_dir"
warn " [3/3] Done."
summary+=("$key -> $zip_path ($(z_size_mb "$zip_path") MB)")
}
backup_scripts() {
local out_dir zip_path scripts_root
scripts_root="$(zq '.paths.scriptsRoot')"
out_dir="$BACKUP_ROOT/scripts"; mkdir -p "$out_dir"
zip_path="$out_dir/$(ts)_scripts$(tag_suffix).zip"
printf '\n'; info "=== [SCRIPTS] Local backup (this scripts folder) ==="
dim " Output: $zip_path"
[ -d "$scripts_root" ] || { err " Scripts root not found: $scripts_root"; return 1; }
warn " [1/2] Archiving source..."
z_archive "$scripts_root" "$zip_path" 1 "-" .git archive tmp nul || return 1
warn " [2/2] Done."
summary+=("scripts -> $zip_path ($(z_size_mb "$zip_path") MB)")
}
for key in "${projects[@]+"${projects[@]}"}"; do
backup_project "$key" || { summary+=("$key -> FAILED"); exit_code=1; }
done
if [ "$include_scripts" -eq 1 ]; then
backup_scripts || { summary+=("scripts -> FAILED"); exit_code=1; }
fi
printf '\n'; info "=== Backup summary ==="
for line in "${summary[@]+"${summary[@]}"}"; do
case "$line" in *FAILED*) err " $line" ;; *) ok " $line" ;; esac
done
printf '\n'
exit "$exit_code"

47
bash/zbackup_and_sync Normal file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels · dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
#
# zbackup_and_sync — run backups, then sync the backups folder offsite.
#
# Usage:
# zbackup_and_sync # backup everything + sync
# zbackup_and_sync <project> [<project> ...]
#
# Cron example (see setup_backup_schedule):
# 0 2 * * * /path/to/zscripts/bash/zbackup_and_sync >> ~/zbackup.log 2>&1
set -uo pipefail
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_HERE/zhelpers.sh"
z_need_config
printf '\n'
info "============================================"
info " Backup & Sync - $(date '+%Y-%m-%d %H:%M:%S')"
info "============================================"
printf '\n'
warn "[1/2] Running backups..."
"$_HERE/zbackup" "$@"
backup_rc=$?
if [ $backup_rc -ne 0 ]; then
err "Backup failed (exit code: $backup_rc)"
exit $backup_rc
fi
printf '\n'; warn "[2/2] Syncing offsite..."
"$_HERE/zsync"
sync_rc=$?
printf '\n'
info "============================================"
if [ $sync_rc -eq 0 ]; then
ok " Backup & Sync Complete [OK]"
else
warn " Sync had issues (exit code: $sync_rc)"
fi
info "============================================"
printf '\n'
exit $sync_rc

99
bash/zbackup_ec2 Normal file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels · dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
#
# zbackup_ec2 — pull backups down from the server: a Postgres dump for projects
# 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> ...]
#
# Output:
# <paths.backupsEc2>/<project>/<timestamp>_<project>_db.sql (projects with a db block)
# <paths.backupsEc2>/<project>/<timestamp>_<project>_files.zip
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require ssh scp
projects=("$@")
if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.remote.path != null) | .key' "$ZCONFIG")
fi
EC2_BACKUP_ROOT="$(zq '.paths.backupsEc2')"
REMOTE_HOME="/home/$(zec2_user)"
scp_down() { # <remote_path> <local_path>
scp -o StrictHostKeyChecking=no -i "$(zec2_pem)" "$(zec2_target):$1" "$2" \
|| { err "SCP download failed: $1 -> $2"; return 1; }
}
declare -a summary
exit_code=0
ec2_backup() { # <key>
local key="$1" remote_path compose_dir ts out_dir remote_db remote_zip local_db local_zip
local db_user db_name has_db=0 line
zproj_require "$key"
remote_path="$(zproj "$key" .remote.path)"
compose_dir="$(zremote_compose_dir "$key")"
ts="$(date +%Y%m%d-%H%M%S)"
out_dir="$EC2_BACKUP_ROOT/$key"; mkdir -p "$out_dir"
remote_db="$REMOTE_HOME/ec2_${key}_db_$ts.sql"
remote_zip="$REMOTE_HOME/ec2_${key}_files_$ts.zip"
local_db="$out_dir/${ts}_${key}_db.sql"
local_zip="$out_dir/${ts}_${key}_files.zip"
db_user="$(zproj "$key" .db.user)"; db_name="$(zproj "$key" .db.name)"
[ -n "$db_user" ] && [ -n "$db_name" ] && has_db=1
printf '\n'; info "=== [$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')] Server backup ($(zproj "$key" .label)) ==="
if [ "$has_db" -eq 1 ]; then
warn " [1/4] PostgreSQL dump on the server..."
zec2_step "pg_dump $db_name" \
"cd $compose_dir && sudo docker compose exec -T db pg_dump -U $db_user -d $db_name > $remote_db" || return 1
else
dim " [1/4] No db config block - skipping database dump."
fi
warn " [2/4] Zipping server-side data (uploads/archive/dist if present)..."
local zip_cmd
zip_cmd="sudo apt-get install -y zip >/dev/null 2>&1"
zip_cmd+="; FILES=''"
zip_cmd+="; test -d $remote_path/uploads && FILES=\"\$FILES $remote_path/uploads\""
zip_cmd+="; test -d $remote_path/archive && FILES=\"\$FILES $remote_path/archive\""
zip_cmd+="; test -d $remote_path/dist && FILES=\"\$FILES $remote_path/dist\""
zip_cmd+="; test -f $remote_path/build-version.json && FILES=\"\$FILES $remote_path/build-version.json\""
zip_cmd+="; if [ -z \"\$FILES\" ]; then echo 'no data dirs found' | sudo zip $remote_zip - >/dev/null; else sudo zip -r $remote_zip \$FILES; fi"
if ! zec2_step "zip $key files" "$zip_cmd"; then
warn " WARNING: files zip failed - writing placeholder and continuing."
zec2_step "placeholder zip" "echo 'zip failed' | sudo zip $remote_zip - >/dev/null" || true
fi
warn " [3/4] Downloading to local..."
[ "$has_db" -eq 1 ] && { scp_down "$remote_db" "$local_db" || return 1; }
scp_down "$remote_zip" "$local_zip" || return 1
warn " [4/4] Cleaning up remote..."
zec2_step "remove remote temp files" "rm -f $remote_db $remote_zip" || true
line="$key ->"
[ "$has_db" -eq 1 ] && [ -f "$local_db" ] && line+=" $local_db ($(z_size_mb "$local_db") MB) |"
line+=" $local_zip ($(z_size_mb "$local_zip") MB)"
summary+=("$line")
}
for key in "${projects[@]+"${projects[@]}"}"; do
ec2_backup "$key" || { summary+=("$key -> FAILED"); exit_code=1; }
done
printf '\n'; info "=== Server backup summary ==="
for line in "${summary[@]+"${summary[@]}"}"; do
case "$line" in *FAILED*) err " $line" ;; *) ok " $line" ;; esac
done
printf '\n'
exit "$exit_code"

80
bash/zconfig.example.json Normal file
View File

@ -0,0 +1,80 @@
{
"_comment": "Copy this file to zconfig.json and fill in your values. zconfig.json is gitignored — never commit it.",
"ec2": {
"ip": "YOUR_SERVER_IP",
"user": "YOUR_SSH_USER",
"pemKey": "/home/youruser/.ssh/YourKey.pem",
"stackRoot": "/home/YOUR_SSH_USER/stack"
},
"paths": {
"temp": "/tmp/zscripts",
"backupsLocal": "/home/youruser/backups/projects",
"backupsEc2": "/home/youruser/backups/ec2",
"scriptsRoot": "/home/youruser/zscripts/bash",
"oneDriveBackups": ""
},
"projects": {
"_comment": "Rename these keys to your own project names — the key IS the command argument: zstart pyapp, zdeploy viteapp, zbackup nextapp. Add as many projects as you like. Keys starting with _ are ignored.",
"pyapp": {
"label": "My Python App",
"kind": "python",
"localRoot": "/home/youruser/code/pyapp",
"startModule": "pyapp.main",
"ports": { "dev": 8080 },
"domain": "pyapp.yourdomain.com",
"start": {
"_comment": "Optional zstart pre-steps: gitPull runs 'git pull --ff-only' first; env sets variables for the server process.",
"gitPull": true,
"env": { "MYAPP_DEBUG": "1" }
},
"db": { "user": "pyapp_user", "name": "pyapp_db" },
"remote": {
"path": "/home/YOUR_SSH_USER/stack/pyapp",
"composeDir": "/home/YOUR_SSH_USER/stack/pyapp/docker",
"appService": "app"
},
"deploy": { "zipName": "PyAppDeploy.zip", "gitPull": true, "exclude": ["docs"] }
},
"viteapp": {
"label": "My Vite Site",
"kind": "vite",
"localRoot": "/home/youruser/code/viteapp",
"ports": { "dev": 5173 },
"domain": "www.yourdomain.com",
"remote": {
"path": "/home/YOUR_SSH_USER/stack/viteapp",
"containerName": "viteapp"
},
"deploy": { "zipName": "ViteAppDeploy.zip" }
},
"nextapp": {
"label": "My Next.js App",
"kind": "nextjs",
"localRoot": "/home/youruser/code/nextapp",
"ports": { "dev": 4173, "prod": 3000 },
"domain": "app.yourdomain.com",
"migrations": "prisma",
"db": { "user": "nextapp_user", "name": "nextapp_db" },
"remote": {
"path": "/home/YOUR_SSH_USER/stack/nextapp",
"appService": "web"
},
"deploy": { "zipName": "NextAppDeploy.zip" }
},
"edge": {
"label": "Edge Proxy",
"kind": "edge",
"localRoot": "/home/youruser/code/edge",
"proxyContainer": "edge-proxy",
"certsSource": "/etc/letsencrypt",
"remote": { "path": "/home/YOUR_SSH_USER/stack/edge" }
}
}
}

494
bash/zdeploy Normal file
View File

@ -0,0 +1,494 @@
#!/usr/bin/env bash
# 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.
#
# zdeploy — deploy any project defined in zconfig.json to the server.
# Each project runs its own docker compose stack; the handler is picked by the
# project's "kind": python | vite | nextjs | edge | docker.
#
# Usage:
# zdeploy <project> [<project> ...] [--note "message"]
# zdeploy all # every project (edge kinds first), stop at first failure
#
# Flow (python/vite/nextjs): zip source -> free server disk space -> scp up ->
# unzip into remote.path (preserving server-side .env) -> docker compose build + up
# -> verify the live site reports the new build version. Zips are always deleted.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require ssh scp zip curl jq
projects=(); NOTE="Build deployed"
while [ $# -gt 0 ]; do
case "$1" in
--note) NOTE="${2:-Build deployed}"; shift 2 ;;
*) projects+=("${1#-}"); shift ;; # tolerate switch-style keys (zdeploy -myproject)
esac
done
if [ "${#projects[@]}" -eq 0 ]; then
printf '\n'; warn "Usage: zdeploy <project> [<project> ...] | all [--note \"message\"]"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' deploys everything (edge kinds first) and stops at the first failure."
exit 1
fi
EC2_IP="$(zec2_ip)"
STACK_ROOT="$(zq '.ec2.stackRoot')"
REMOTE_HOME="/home/$(zec2_user)"
EC2_USER="$(zec2_user)"
TEMP_ROOT="$(zq '.paths.temp')"
{ [ -z "$TEMP_ROOT" ] || [ "$TEMP_ROOT" = "null" ]; } && TEMP_ROOT="${TMPDIR:-/tmp}"
mkdir -p "$TEMP_ROOT"
# 'all' -> every project, edge kinds first (the proxy must route before apps ship).
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
mapfile -t edge_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG")
mapfile -t rest_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind != "edge") | .key' "$ZCONFIG")
projects=("${edge_keys[@]+"${edge_keys[@]}"}" "${rest_keys[@]+"${rest_keys[@]}"}")
info "Deploying all projects: $(printf '%s ' "${projects[@]}")"
fi
deploy_zip_name() { # <key>
local zn; zn="$(zproj "$1" .deploy.zipName)"
[ -n "$zn" ] && printf '%s' "$zn" || printf '%sDeploy.zip' "$1"
}
# Pre-upload cleanup: remove stale deploy zips, prune docker, truncate big logs,
# fail if under 1.5 GB free.
preflight_cleanup() { # <extra_zip_to_remove...>
printf '\n'; info "--- [Preflight] Freeing disk space on the server ---"
local rm_clause="true"
[ $# -gt 0 ] && rm_clause="rm -f $*"
local cmd
cmd="echo '--- df / before cleanup ---'; df -h /"
cmd+="; echo '--- removing stale deploy artifacts ---'; $rm_clause"
cmd+="; echo '--- pruning docker build cache + dangling images + stopped containers ---'"
cmd+="; sudo docker container prune -f >/dev/null 2>&1 || true"
cmd+="; sudo docker builder prune -f >/dev/null 2>&1 || true"
cmd+="; sudo docker image prune -af >/dev/null 2>&1 || true"
cmd+="; echo '--- truncating large container logs ---'"
cmd+="; sudo find /var/lib/docker/containers/ -name '*-json.log' -size +50M -exec truncate -s 0 {} + 2>/dev/null || true"
cmd+="; echo '--- df / after cleanup ---'; df -h /"
cmd+='; avail_mb=$(df --output=avail -BM / | tail -n 1 | tr -dc 0-9)'
cmd+='; [ -z "$avail_mb" ] && avail_mb=0'
cmd+='; echo available_mb=$avail_mb'
cmd+='; if [ "$avail_mb" -lt 1500 ]; then echo "ERROR: less than 1.5 GB free on /. Grow the root volume or run: sudo docker system prune -af" >&2; exit 11; fi'
zssh "$cmd" || { err "Server pre-flight cleanup failed. Root volume too full (need ~1.5 GB free, ideally 3+)."; return 1; }
}
# Post-deploy cleanup: prune what this deploy created; running stacks untouched.
post_cleanup() { # <label>
printf '\n'; info "--- [Post-deploy] Reclaiming disk space ($1) ---"
local cmd
cmd="sudo docker container prune -f >/dev/null 2>&1 || true"
cmd+="; sudo docker builder prune -f >/dev/null 2>&1 || true"
cmd+="; sudo docker image prune -af >/dev/null 2>&1 || true"
cmd+="; sudo find /var/lib/docker/containers/ -name '*-json.log' -size +50M -exec truncate -s 0 {} + 2>/dev/null || true"
cmd+="; df -h /"
zssh "$cmd" || warn " Post-deploy cleanup returned non-zero; continuing."
}
send_zip() { # <local_zip> <zip_name>
scp -i "$(zec2_pem)" "$1" "$(zec2_target):$REMOTE_HOME/" \
|| { err "SCP upload failed. Likely server disk space. Try: rm -f $REMOTE_HOME/$2"; return 1; }
}
remote_unzip() { # <zip_name> <dest_path>
zec2_step "unzip $1" \
"test -f $REMOTE_HOME/$1 || { echo 'missing $REMOTE_HOME/$1'; exit 2; }; unzip -t $REMOTE_HOME/$1 || exit 3; unzip -o $REMOTE_HOME/$1 -d $2; uc=\$?; if [ \$uc -gt 1 ]; then exit \$uc; fi; exit 0"
}
# ── Deploy verification (build-version match, not just HTTP 200 — a 200 can be
# a stale cached build; the version match proves the new build is live) ─────
wait_verify_api() { # <key> <expected_label> <timeout_sec>
local key="$1" expected="$2" timeout="${3:-60}" domain live deadline
domain="$(zproj "$key" .domain)"
printf '\n'; info "--- [$key] Live build verification (expect $expected) ---"
deadline=$((SECONDS + timeout))
while [ $SECONDS -lt $deadline ]; do
live="$(http_get "http://$EC2_IP/api/build-version" "$domain" | jq -r '.build_version // empty' 2>/dev/null)"
if [ -n "$live" ]; then
if [ "$live" = "$expected" ]; then
ok " PASS - live build $live matches expected."
return 0
fi
warn " Live build is $live, expected $expected - waiting..."
else
dim " /api/build-version not ready yet - waiting..."
fi
sleep 3
done
warn " WARNING: live build did not match $expected within ${timeout}s (a stale build may be cached)."
return 1
}
wait_verify_static() { # <key> <pre_product_version> <pre_build_number>
local key="$1" pv="$2" bn="$3" expected container domain live deadline raw
if [ -z "$pv" ] || [ -z "$bn" ]; then
printf '\n'; warn "--- [$key version] SKIPPED (no local build-version.json - see 'Enabling deploy verification' in README) ---"
return 0
fi
expected="v${pv}.$((bn + 1))"
container="$(zproj "$key" .remote.containerName)"
domain="$(zproj "$key" .domain)"
printf '\n'; info "--- [$key] Live build verification (expect $expected) ---"
deadline=$((SECONDS + 45))
while [ $SECONDS -lt $deadline ]; do
if [ -n "$container" ]; then
# build-version.json may be blocked externally by the edge proxy;
# read it inside the running container instead.
raw="$(zssh "sudo docker exec $container cat /usr/share/nginx/html/build-version.json 2>/dev/null" 2>/dev/null)"
else
raw="$(http_get "http://$EC2_IP/build-version.json" "$domain")"
fi
live="$(printf '%s' "$raw" | jq -r '"v\(.productVersion).\(.buildNumber)"' 2>/dev/null)"
if [ -n "$live" ] && [ "$live" != "null" ] && [ "$live" != "v." ]; then
if [ "$live" = "$expected" ]; then
ok " PASS - live build $live matches expected."
return 0
fi
warn " Live build is $live, expected $expected - waiting..."
else
dim " Container not ready yet - waiting..."
fi
sleep 3
done
warn " WARNING: live build did not match $expected within 45s (upload or Docker build may have failed, or a stale build is cached)."
}
# Fast-forward the checkout before a deploy when deploy.gitPull is set — zdeploy
# zips the working tree, so a stale checkout would ship stale code while still
# bumping the build number. Abort rather than deploy uncertain code.
deploy_git_pull() { # <key>
local key="$1" root branch
[ "$(zproj "$key" .deploy.gitPull)" = "true" ] || return 0
root="$(zproj "$key" .localRoot)"
if [ ! -d "$root/.git" ]; then
warn " gitPull set but '$root' is not a git repo - skipping pull."
return 0
fi
printf '\n'; info "--- [0] git pull --ff-only ---"
branch="$(cd "$root" && git rev-parse --abbrev-ref HEAD)"
dim " Branch: $branch"
(cd "$root" && git pull --ff-only) || {
err "git pull --ff-only failed in '$root' (branch '$branch'). Resolve it, then re-run - refusing to deploy possibly-stale code."
return 1
}
dim " Now at: $(cd "$root" && git log -1 --oneline)"
}
# ── Kind handlers ────────────────────────────────────────────────────────────
deploy_python() { # <key>
local key="$1" start=$SECONDS root remote_path compose_dir app_svc zip_name zip_local
local bv="" has_version_tool=0 excl=() attempt out label domain
root="$(zproj "$key" .localRoot)"
remote_path="$(zproj "$key" .remote.path)"
compose_dir="$(zremote_compose_dir "$key")"
app_svc="$(zproj "$key" .remote.appService)"; [ -n "$app_svc" ] || app_svc="app"
zip_name="$(deploy_zip_name "$key")"
zip_local="$TEMP_ROOT/$zip_name"
label="$(zproj "$key" .label)"
domain="$(zproj "$key" .domain)"
[ -f "$root/scripts/build_version_tool.py" ] && has_version_tool=1
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
printf '\n'; info "=== $label deploy (python) ==="
dim "Local zip: $zip_local"
printf '\n'; info "--- [1] Zipping $label ---"
find "$root" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
mapfile -t excl < <(z_archive_excludes "$key" 0)
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
printf '\n'; info "--- [2] Uploading zip ---"
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
rm -f "$zip_local"
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
zec2_step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
zec2_step "backup .env if present" "if [ -f $remote_path/.env ]; then cp $remote_path/.env $REMOTE_HOME/.env.${key}_bak; fi" || return 1
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
remote_unzip "$zip_name" "$remote_path" || return 1
zec2_step "restore .env from backup" "if [ -f $REMOTE_HOME/.env.${key}_bak ]; then cp $REMOTE_HOME/.env.${key}_bak $remote_path/.env; fi" || return 1
zec2_step "require compose directory" "test -d $compose_dir" || return 1
zec2_step "docker compose build $app_svc" "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose build $app_svc" || return 1
zec2_step "docker compose up -d" "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
zec2_step "record deploy time; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
if [ "$has_version_tool" -eq 1 ]; then
printf '\n'; info "--- [4] Incrementing build version ---"
for attempt in 1 2 3 4 5; do
out="$(zssh "cd $compose_dir && sudo docker compose exec -T $app_svc python scripts/build_version_tool.py bump" 2>/dev/null | tail -1)"
[ -n "$out" ] && { bv="$out"; break; }
warn " Attempt $attempt failed, retrying in 3s..."
sleep 3
done
[ -n "$bv" ] || { err "Build version bump failed after 5 attempts"; return 1; }
(cd "$root" && { python3 scripts/build_version_tool.py set "$bv" >/dev/null 2>&1 || python scripts/build_version_tool.py set "$bv" >/dev/null 2>&1; })
zssh "echo '$bv' | sudo tee $remote_path/.build_version > /dev/null"
if [ -f "$root/scripts/build_changelog_tool.py" ]; then
(cd "$root" && { python3 scripts/build_changelog_tool.py append --version "$bv" --note "$NOTE" >/dev/null 2>&1 \
|| python scripts/build_changelog_tool.py append --version "$bv" --note "$NOTE" >/dev/null 2>&1; })
fi
printf '\n'; info "--- [5] Restarting app to pick up new version ---"
zssh "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose restart $app_svc" \
|| { err "App restart after build bump failed"; return 1; }
wait_verify_api "$key" "$bv" 30 || true
else
printf '\n'; info "--- [4] Basic reachability check (no build_version_tool - see 'Enabling deploy verification' in README) ---"
local deadline=$((SECONDS + 30)) code up=0
while [ $SECONDS -lt $deadline ]; do
sleep 3
code="$(http_code "http://$EC2_IP/" "$domain")"
if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then up=1; break; fi
dim " App not ready yet - waiting..."
done
if [ "$up" -eq 1 ]; then ok " App is responding."; else warn " WARNING: app did not respond within 30s."; fi
fi
post_cleanup "$key"
local elapsed=$((SECONDS - start))
printf '\n'; ok "--- [Done] $label deployed! ---"
[ -n "$domain" ] && warn "Site: https://$domain"
[ -n "$bv" ] && note "Build Version: $bv"
info "Change Note: $NOTE"
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
}
deploy_vite() { # <key>
local key="$1" start=$SECONDS root remote_path zip_name zip_local excl=()
local pre_pv="" pre_bn="" label domain edge_pc
root="$(zproj "$key" .localRoot)"
remote_path="$(zproj "$key" .remote.path)"
zip_name="$(deploy_zip_name "$key")"
zip_local="$TEMP_ROOT/$zip_name"
label="$(zproj "$key" .label)"
domain="$(zproj "$key" .domain)"
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
printf '\n'; info "=== $label deploy (vite/static) ==="
dim "Local zip: $zip_local"
printf '\n'; info "--- [1] Zipping site ---"
if [ -f "$root/build-version.json" ]; then
pre_pv="$(jq -r '.productVersion // empty' "$root/build-version.json")"
pre_bn="$(jq -r '.buildNumber // empty' "$root/build-version.json")"
[ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
fi
mapfile -t excl < <(z_archive_excludes "$key" 0)
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
printf '\n'; info "--- [2] Uploading zip ---"
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
rm -f "$zip_local"
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
zec2_step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
remote_unzip "$zip_name" "$remote_path" || return 1
zec2_step "require compose file" "test -f $remote_path/docker-compose.yml" || return 1
zec2_step "docker compose build" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose build" || return 1
zec2_step "docker compose up -d" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
local ek; ek="$(zedge_key)"
if [ -n "$ek" ]; then
edge_pc="$(zproj "$ek" .proxyContainer)"
[ -n "$edge_pc" ] && { zec2_step "reload edge nginx (flush DNS cache for new container IP)" "sudo docker exec $edge_pc nginx -s reload" || return 1; }
fi
zec2_step "record deploy time; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
wait_verify_static "$key" "$pre_pv" "$pre_bn"
post_cleanup "$key"
local elapsed=$((SECONDS - start))
printf '\n'; ok "--- [Done] $label deployed! ---"
[ -n "$domain" ] && warn "Site: https://$domain"
info "Change Note: $NOTE"
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
}
deploy_next() { # <key>
local key="$1" start=$SECONDS root remote_path app_svc zip_name zip_local excl=()
local pre_pv="" pre_bn="" label domain db_user db_name prod_port
root="$(zproj "$key" .localRoot)"
remote_path="$(zproj "$key" .remote.path)"
app_svc="$(zproj "$key" .remote.appService)"; [ -n "$app_svc" ] || app_svc="web"
zip_name="$(deploy_zip_name "$key")"
zip_local="$TEMP_ROOT/$zip_name"
label="$(zproj "$key" .label)"
domain="$(zproj "$key" .domain)"
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
printf '\n'; info "=== $label deploy (nextjs) ==="
dim "Local zip: $zip_local"
if [ -f "$root/public/build-version.json" ]; then
pre_pv="$(jq -r '.productVersion // empty' "$root/public/build-version.json")"
pre_bn="$(jq -r '.buildNumber // empty' "$root/public/build-version.json")"
[ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
fi
printf '\n'; info "--- [1] Zipping project files ---"
mapfile -t excl < <(z_archive_excludes "$key" 0)
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
printf '\n'; info "--- [2] Uploading zip ---"
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
rm -f "$zip_local"
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
zec2_step "ensure unzip installed" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
zec2_step "backup .env if present" "if [ -f $remote_path/.env ]; then cp $remote_path/.env $REMOTE_HOME/.env.${key}_bak; fi" || return 1
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
remote_unzip "$zip_name" "$remote_path" || return 1
zec2_step "restore .env from backup" "if [ -f $REMOTE_HOME/.env.${key}_bak ]; then cp $REMOTE_HOME/.env.${key}_bak $remote_path/.env; fi" || return 1
printf '\n'; info "--- [4] Docker compose rebuild ---"
zec2_step "docker compose down" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose down" || return 1
zec2_step "docker compose build" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose build" || return 1
zec2_step "docker compose up -d" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
db_user="$(zproj "$key" .db.user)"; db_name="$(zproj "$key" .db.name)"
if [ -n "$db_user" ] && [ -n "$db_name" ]; then
zec2_step "wait for postgres ready" \
"cd $remote_path && for i in \$(seq 1 30); do sudo docker compose exec -T db pg_isready -U $db_user -d $db_name >/dev/null 2>&1 && break; sleep 2; done" || return 1
fi
if [ "$(zproj "$key" .migrations)" = "prisma" ]; then
zec2_step "apply prisma migrations" "cd $remote_path && sudo docker compose exec -T $app_svc npx prisma migrate deploy" || return 1
fi
zec2_step "record deploy timestamp; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
printf '\n'; info "--- [5] Verifying deployment ---"
prod_port="$(zproj "$key" .ports.prod)"
if [[ "$prod_port" =~ ^[0-9]+$ ]]; then
local direct="http://${EC2_IP}:${prod_port}/" deadline=$((SECONDS + 60)) code verified=0
while [ $SECONDS -lt $deadline ]; do
sleep 4
code="$(http_code "$direct")"
if [ "$code" = "200" ]; then
ok " PASS - app is responding at $direct"
verified=1; break
fi
dim " App not ready yet - waiting..."
done
[ "$verified" -eq 1 ] || warn " WARNING: no response at $direct within 60s (is the port open in the security group?)."
fi
if [ -n "$pre_pv" ]; then
wait_verify_api "$key" "v${pre_pv}.$((pre_bn + 1))" 60 || true
else
warn " (No public/build-version.json - version verification skipped. See 'Enabling deploy verification' in README.)"
fi
post_cleanup "$key"
local elapsed=$((SECONDS - start))
printf '\n'; ok "--- [Done] $label deployed! ---"
[ -n "$domain" ] && warn "Site: https://$domain"
info "Change Note: $NOTE"
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
}
deploy_edge() { # <key>
local key="$1" root remote_path pc label cert_mount="" rm_stale="" f
root="$(zproj "$key" .localRoot)"
remote_path="$(zproj "$key" .remote.path)"
pc="$(zproj "$key" .proxyContainer)"
label="$(zproj "$key" .label)"
printf '\n'; info "=== $label deploy (edge nginx ingress) ==="
[ -d "$root" ] || { err "Edge root not found: $root"; return 1; }
for f in docker-compose.yml nginx.conf; do
[ -f "$root/$f" ] || { err "Missing $root/$f"; return 1; }
done
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
zec2_step "ensure edge dir" "sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
# Ship every top-level file in the edge folder — nginx.conf, compose, css,
# htpasswd, whatever the proxy serves. Subdirectories (logs, certs) stay put.
while IFS= read -r -d '' f; do
info " >> uploading $(basename "$f")"
scp -i "$(zec2_pem)" "$f" "$(zec2_target):$remote_path/" \
|| { err "SCP failed for $(basename "$f")"; return 1; }
done < <(find "$root" -maxdepth 1 -type f ! -name nul -print0)
local certs; certs="$(zproj "$key" .certsSource)"
[ -n "$certs" ] && cert_mount="-v $certs:/etc/letsencrypt/:ro "
zec2_step "validate new nginx.conf" \
"sudo docker run --rm -v $remote_path/nginx.conf:/etc/nginx/nginx.conf:ro ${cert_mount}nginx:1.27-alpine nginx -t -c /etc/nginx/nginx.conf" || return 1
# A container from an older compose project may still hold the proxy name;
# docker refuses a second create with the same name, so remove it first.
[ -n "$pc" ] && rm_stale="; sudo docker rm -f $pc 2>/dev/null || true"
zec2_step "edge: compose down + remove stale proxy" "cd $remote_path && sudo docker compose down 2>/dev/null || true$rm_stale" || return 1
zec2_step "edge compose up -d" "cd $remote_path && sudo docker compose up -d" || return 1
[ -n "$pc" ] && { zec2_step "edge nginx reload" "sudo docker exec $pc nginx -s reload || true" || return 1; }
zec2_step "fix nginx-logs permissions (if present)" \
"if [ -d $remote_path/nginx-logs ]; then sudo chmod 777 $remote_path/nginx-logs; sudo chmod 666 $remote_path/nginx-logs/*.log 2>/dev/null || true; fi" || return 1
ok "--- [Done] Edge proxy deploy finished ---"
}
deploy_docker() { # <key>
local key="$1" root remote_path label f domain
root="$(zproj "$key" .localRoot)"
remote_path="$(zproj "$key" .remote.path)"
label="$(zproj "$key" .label)"
domain="$(zproj "$key" .domain)"
printf '\n'; info "=== $label deploy (docker compose) ==="
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
[ -f "$root/docker-compose.yml" ] || { err "Missing $root/docker-compose.yml"; return 1; }
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
zec2_step "ensure project dir" "sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
while IFS= read -r -d '' f; do
info " >> uploading $(basename "$f")"
scp -i "$(zec2_pem)" "$f" "$(zec2_target):$remote_path/" \
|| { err "SCP failed for $(basename "$f")"; return 1; }
done < <(find "$root" -maxdepth 1 -type f ! -name nul -print0)
zec2_step "docker compose pull" "cd $remote_path && sudo docker compose pull" || return 1
zec2_step "docker compose up -d" "cd $remote_path && sudo docker compose up -d" || return 1
post_cleanup "$key"
printf '\n'; ok "--- [Done] $label deploy finished ---"
[ -n "$domain" ] && warn "Site: https://$domain"
}
# ── Dispatch ─────────────────────────────────────────────────────────────────
for key in "${projects[@]}"; do
zproj_require "$key"
deploy_git_pull "$key" || exit 1
kind="$(zproj "$key" .kind)"
case "$kind" in
python) deploy_python "$key" || exit 1 ;;
vite) deploy_vite "$key" || exit 1 ;;
nextjs) deploy_next "$key" || exit 1 ;;
edge) deploy_edge "$key" || exit 1 ;;
docker) deploy_docker "$key" || exit 1 ;;
*) err "No deploy handler for kind '$kind' (project '$key'). Add a deploy_<kind> function in zdeploy."; exit 1 ;;
esac
done

93
bash/zec2 Normal file
View File

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# 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 — 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 viteapp --host 203.0.113.10
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require curl
projects=(); host_override=""
while [ $# -gt 0 ]; do
case "$1" in
--host) host_override="${2:-}"; shift 2 ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
HOST="${host_override:-$(zec2_ip)}"
if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(zproj_keys_with_domain)
if [ "${#projects[@]}" -eq 0 ]; then
warn "No projects with a 'domain' configured in zconfig.json."
exit 1
fi
fi
check_reachability() { # <label> <port> <host-header>
local label="$1" port="$2" hh="$3" code
printf '\n'; info "=== zec2: $label ==="
dim " Target: ${HOST}:${port}${hh:+ (Host: $hh)}"
printf '\n'
warn " [1/2] TCP connect to port ${port}..."
if tcp_check "$HOST" "$port"; then
ok " OK - port ${port} is open (TCP succeeded)"
else
err " FAIL - port ${port} did not accept TCP (check firewall/security group + app on the server)"
return 1
fi
warn " [2/2] HTTP GET http://${HOST}:${port}/ ..."
code="$(http_code "http://${HOST}:${port}/" "$hh")"
if [ "$code" = "000" ]; then
err " FAIL - no HTTP response (connection dropped or timed out)"
return 1
elif [ "$code" -lt 400 ]; then
ok " OK - HTTP $code"
else
warn " HTTP response: $code (connection worked; app may redirect or require auth)"
fi
printf '\n'; info " Done ($label)."
return 0
}
show_live_version() { # <key>
local label
label="$(remote_version_label "$1" "$HOST")"
[ "$label" != "unknown" ] && dim " Live build: $label" \
|| dim " (Could not read live version endpoint - see 'Enabling deploy verification' in README)"
}
exit_code=0
for key in "${projects[@]}"; do
zproj_require "$key"
domain="$(zproj "$key" .domain)"
if [ -z "$domain" ]; then
printf '\n'; warn "Skipping '$key' - no domain configured."
continue
fi
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
if check_reachability "$label" 80 "$domain"; then
show_live_version "$key"
else
exit_code=1
fi
done
printf '\n'
info "If TCP fails: open inbound TCP in the server's firewall/security group."
info "If TCP OK but HTTP fails: SSH in and check docker/nginx (curl -sI http://127.0.0.1/)."
printf '\n'
exit "$exit_code"

148
bash/zec2online Normal file
View File

@ -0,0 +1,148 @@
#!/usr/bin/env bash
# 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.
#
# zec2online — deep health check: verify apps are live AND running the expected
# build; auto-start downed stacks via docker compose and stream diagnostics.
#
# Usage:
# zec2online # every project with a "domain" in zconfig.json
# zec2online <project> [<project> ...]
#
# 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.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require curl ssh
projects=(); host_override=""
while [ $# -gt 0 ]; do
case "$1" in
--host) host_override="${2:-}"; shift 2 ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
HOST="${host_override:-$(zec2_ip)}"
EDGE_KEY="$(zedge_key)"
if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(zproj_keys_with_domain)
if [ "${#projects[@]}" -eq 0 ]; then
warn "No projects with a 'domain' configured in zconfig.json."
exit 1
fi
fi
compare_versions() { # <key> <local> <server>
local key="$1" local_v="$2" server_v="$3"
if [ "$local_v" = "unknown" ] && [ "$server_v" = "unknown" ]; then
dim " Version: unable to determine local or server version (see 'Enabling deploy verification' in README)"
elif [ "$local_v" = "unknown" ]; then
note " Server: $server_v"; dim " Local: unknown (could not read local build version)"
elif [ "$server_v" = "unknown" ]; then
note " Local: $local_v"; dim " Server: unknown (could not read server build version)"
elif [ "$local_v" = "$server_v" ]; then
ok " Version: $server_v (local and server match)"
else
note " Local: $local_v"; note " Server: $server_v"
warn " WARNING: local and server versions differ - redeploy with: zdeploy $key"
fi
}
test_http_online() { # <host-header>
local code
code="$(http_code "http://${HOST}/" "$1")"
if [ "$code" = "000" ]; then return 1; fi
if [ "$code" -lt 500 ]; then
if [ "$code" -ge 300 ]; then dim " HTTP $code - redirect from app, server is live."
else dim " HTTP $code - service reachable."; fi
return 0
fi
warn " HTTP $code - unexpected server response."
return 1
}
show_diagnostics() { # <key> <reason>
local key="$1" reason="$2" compose_dir cmd pc
[ -f "$(zec2_pem)" ] || { dim " Diagnostics skipped: PEM key not found at $(zec2_pem)"; return; }
compose_dir="$(zremote_compose_dir "$key")"
printf '\n'; note " --- $key diagnostics ($reason) ---"
cmd="echo '== services =='; cd $compose_dir 2>/dev/null && sudo docker compose ps"
cmd+="; echo '== uptime / df =='; uptime; df -h /"
cmd+="; echo '== oom =='; dmesg -T 2>/dev/null | grep -iE 'oom|killed' | tail -n 10"
cmd+="; echo '== app logs (80) =='; cd $compose_dir 2>/dev/null && sudo docker compose logs --tail 80"
if [ -n "$EDGE_KEY" ]; then
pc="$(zproj "$EDGE_KEY" .proxyContainer)"
if [ -n "$pc" ]; then
cmd+="; echo '== edge proxy state =='; sudo docker ps --filter name=$pc --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
cmd+="; echo '== edge proxy logs (40) =='; sudo docker logs --tail 40 $pc 2>&1 | tail -n 40"
fi
fi
zssh "$cmd" || warn " Diagnostics SSH failed."
note " --- end $key diagnostics ---"; printf '\n'
}
online_check() { # <key>
local key="$1" label domain compose_dir start_cmd edge_dir deadline
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
domain="$(zproj "$key" .domain)"
printf '\n'; info "=== zec2online: $label ($key) ==="
dim " Target: http://${HOST}/ (Host: $domain)"
printf '\n'
warn ' [1] Checking TCP + HTTP...'
if test_http_online "$domain"; then
compare_versions "$key" "$(local_version_label "$key")" "$(remote_version_label "$key" "$HOST")"
ok " UP - $label is running."
printf '\n'
return 0
fi
err ' DOWN - Service not responding.'
show_diagnostics "$key" 'DOWN before recovery'
warn ' [2] Starting stack (and edge proxy if defined)...'
compose_dir="$(zremote_compose_dir "$key")"
[ -f "$(zec2_pem)" ] || { err " Cannot start: PEM key not found at: $(zec2_pem)"; return 1; }
start_cmd="sudo docker network create web 2>/dev/null || true"
start_cmd+="; if [ -f $compose_dir/docker-compose.yml ]; then cd $compose_dir && sudo docker compose up -d 2>&1 | tail -10; else echo 'compose missing at $compose_dir'; exit 2; fi"
if [ -n "$EDGE_KEY" ]; then
edge_dir="$(zproj "$EDGE_KEY" .remote.path)"
[ -n "$edge_dir" ] && start_cmd+="; if [ -f $edge_dir/docker-compose.yml ]; then cd $edge_dir && sudo docker compose up -d 2>&1 | tail -10; fi"
fi
zssh "$start_cmd" || { err " SSH failed. Check connectivity and PEM key."; return 1; }
printf '\n'; warn ' [3] Waiting for service (up to 30s)...'
deadline=$((SECONDS + 30))
while [ $SECONDS -lt $deadline ]; do
sleep 3
dim ' checking...'
if test_http_online "$domain"; then
printf '\n'
compare_versions "$key" "$(local_version_label "$key")" "$(remote_version_label "$key" "$HOST")"
ok " UP - $label is now running."
return 0
fi
done
printf '\n'; err ' TIMEOUT - Service did not respond within 30 seconds.'
show_diagnostics "$key" 'recovery TIMEOUT'
return 1
}
exit_code=0
for key in "${projects[@]}"; do
zproj_require "$key"
if [ -z "$(zproj "$key" .domain)" ]; then
printf '\n'; warn "Skipping '$key' - no domain configured."
continue
fi
online_check "$key" || exit_code=1
done
exit "$exit_code"

239
bash/zhelpers.sh Normal file
View File

@ -0,0 +1,239 @@
#!/usr/bin/env bash
# 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.
#
# zhelpers.sh — shared library sourced by every z-script (bash port). Not run directly.
# ---- config location --------------------------------------------------------
_ZDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# zconfig.json lives next to the scripts; override with $ZCONFIG.
ZCONFIG="${ZCONFIG:-$_ZDIR/zconfig.json}"
# ---- colours (only when stdout is a TTY) ------------------------------------
if [ -t 1 ]; then
C_RESET=$'\033[0m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_GRAY=$'\033[90m'; C_MAGENTA=$'\033[35m'
else
C_RESET= C_CYAN= C_GREEN= C_YELLOW= C_RED= C_GRAY= C_MAGENTA=
fi
info() { printf '%s%s%s\n' "$C_CYAN" "$*" "$C_RESET"; }
ok() { printf '%s%s%s\n' "$C_GREEN" "$*" "$C_RESET"; }
warn() { printf '%s%s%s\n' "$C_YELLOW" "$*" "$C_RESET"; }
err() { printf '%s%s%s\n' "$C_RED" "$*" "$C_RESET" >&2; }
dim() { printf '%s%s%s\n' "$C_GRAY" "$*" "$C_RESET"; }
note() { printf '%s%s%s\n' "$C_MAGENTA" "$*" "$C_RESET"; }
# ---- dependency guard -------------------------------------------------------
z_require() {
local miss=0 t
for t in "$@"; do
command -v "$t" >/dev/null 2>&1 || { err "Missing required tool: $t"; miss=1; }
done
[ "$miss" -eq 0 ] || exit 1
}
# ---- config accessors (jq) --------------------------------------------------
z_need_config() {
z_require jq
if [ ! -f "$ZCONFIG" ]; then
err "zconfig.json not found at $ZCONFIG"
dim " Copy zconfig.example.json to zconfig.json and fill in your values."
exit 1
fi
}
zq() { jq -r "$1" "$ZCONFIG"; }
# All project keys, config order, skipping _comment-style keys.
zproj_keys() { zq '.projects | keys_unsorted[] | select(startswith("_") | not)'; }
zproj_csv() { zproj_keys | paste -sd',' -; }
# Keys of projects that have a "domain" (deployed / publicly reachable).
zproj_keys_with_domain() {
jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.domain != null) | .key' "$ZCONFIG"
}
# Field of a project: zproj <key> <dotpath> e.g. zproj sp .kind | zproj sp .ports.dev
zproj() { jq -r --arg k "$1" ".projects[\$k]$2 // empty" "$ZCONFIG"; }
zproj_require() {
if [ "$(jq -r --arg k "$1" '(.projects[$k] != null)' "$ZCONFIG")" != "true" ]; then
err "Unknown project key '$1'. Available: $(zproj_csv)"
exit 1
fi
}
zec2_ip() { zq '.ec2.ip'; }
zec2_user() { zq '.ec2.user'; }
zec2_pem() { zq '.ec2.pemKey'; }
zec2_target() { printf '%s@%s' "$(zec2_user)" "$(zec2_ip)"; }
# Remote compose directory: remote.composeDir if set, else remote.path.
zremote_compose_dir() {
local d
d="$(zproj "$1" .remote.composeDir)"
[ -n "$d" ] || d="$(zproj "$1" .remote.path)"
printf '%s' "$d"
}
# First project of kind 'edge' (or empty).
zedge_key() {
jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG" | head -1
}
# ---- SSH helpers ------------------------------------------------------------
# Run a bash command on the server. zssh is bare; zec2_step adds a label and
# exits non-zero loudly (mirrors Invoke-Ec2Step).
zssh() {
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 -i "$(zec2_pem)" "$(zec2_target)" "$@"
}
zec2_step() {
local label="$1"; shift
dim " >> $label"
zssh "$@"
local rc=$?
if [ $rc -ne 0 ]; then
err "Remote step failed: '$label' (exit $rc)."
return $rc
fi
}
# ---- HTTP / TCP helpers -----------------------------------------------------
# HTTP status code for a URL with optional Host header ("000" on connect fail).
http_code() {
local url="$1" host_header="${2:-}" args=(-s -o /dev/null -w '%{http_code}' -L --max-time 15)
[ -n "$host_header" ] && args+=(-H "Host: $host_header")
curl "${args[@]}" "$url" 2>/dev/null || printf '000'
}
# GET a URL body (with Host header); empty on failure.
http_get() {
local url="$1" host_header="${2:-}" args=(-s --max-time 10)
[ -n "$host_header" ] && args+=(-H "Host: $host_header")
curl "${args[@]}" "$url" 2>/dev/null
}
# TCP connect check via bash /dev/tcp (uses `timeout` when available).
tcp_check() {
local host="$1" port="$2"
if command -v timeout >/dev/null 2>&1; then
timeout 5 bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null
else
bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null
fi
}
# ---- build-version helpers --------------------------------------------------
# "v<productVersion>.<buildNumber>" from a build-version.json file, or "unknown".
json_build_label() {
local f="$1"
[ -f "$f" ] || { printf 'unknown'; return; }
jq -r '"v\(.productVersion).\(.buildNumber)"' "$f" 2>/dev/null || printf 'unknown'
}
# Local build label by project kind (mirrors Get-LocalVersionLabel).
local_version_label() {
local key="$1" kind root out
kind="$(zproj "$key" .kind)"; root="$(zproj "$key" .localRoot)"
case "$kind" in
python)
if [ -f "$root/scripts/build_version_tool.py" ]; then
out="$(cd "$root" && { python3 scripts/build_version_tool.py get 2>/dev/null || python scripts/build_version_tool.py get 2>/dev/null; } | tail -1)"
[ -n "$out" ] && { printf '%s' "$out"; return; }
fi
[ -f "$root/.build_version" ] && { tail -1 "$root/.build_version" | tr -d '[:space:]'; return; }
printf 'unknown' ;;
vite) json_build_label "$root/build-version.json" ;;
nextjs) json_build_label "$root/public/build-version.json" ;;
*) printf 'unknown' ;;
esac
}
# Live/server build label by kind (HTTP endpoints; python falls back to SSH).
remote_version_label() {
local key="$1" host="$2" kind domain body label
kind="$(zproj "$key" .kind)"; domain="$(zproj "$key" .domain)"
if [ "$kind" = "vite" ]; then
body="$(http_get "http://$host/build-version.json" "$domain")"
label="$(printf '%s' "$body" | jq -r '"v\(.productVersion).\(.buildNumber)"' 2>/dev/null)"
[ -n "$label" ] && [ "$label" != "null" ] && { printf '%s' "$label"; return; }
else
body="$(http_get "http://$host/api/build-version" "$domain")"
label="$(printf '%s' "$body" | jq -r '.build_version // empty' 2>/dev/null)"
[ -n "$label" ] && { printf '%s' "$label"; return; }
if [ "$kind" = "python" ] && [ -f "$(zec2_pem)" ]; then
label="$(zssh "cat $(zproj "$key" .remote.path)/.build_version 2>/dev/null || true" 2>/dev/null | tail -1 | tr -d '[:space:]')"
[ -n "$label" ] && { printf '%s' "$label"; return; }
fi
fi
printf 'unknown'
}
# ---- MOTD -------------------------------------------------------------------
# Print a random banner from <root>/motd/*.txt (the PowerShell version keeps a
# shuffled rotation state; the bash port picks at random — same spirit, simpler).
show_project_motd() {
local root="$1" files=()
[ -d "$root/motd" ] || return 0
while IFS= read -r -d '' f; do files+=("$f"); done \
< <(find "$root/motd" -maxdepth 1 -name '*.txt' -type f -print0 2>/dev/null)
[ "${#files[@]}" -gt 0 ] || return 0
printf '\n'; cat "${files[RANDOM % ${#files[@]}]}"; printf '\n'
}
# ---- archive builder --------------------------------------------------------
# Junk filtered out of every deploy/backup zip (mirrors the PowerShell lists).
_Z_JUNK_DIRS=(.git .svn .hg __pycache__ .pytest_cache .mypy_cache .ruff_cache .tox
node_modules .npm .yarn .pnpm-store .next .nuxt .svelte-kit .turbo .parcel-cache
.cache .idea .vscode coverage htmlcov .nyc_output)
_Z_JUNK_EXTS=(swp swo swn tmp orig rej bak backup old pyc pyo pyd tsbuildinfo log
db sqlite sqlite3 zip dmg arj gz tgz tar rar 7z iso bz2 xz lz lzma cab jar war ear z zst zstd)
_Z_SCRIPT_EXTS=(ps1 cmd bat)
_Z_JUNK_NAMES=(.DS_Store Thumbs.db desktop.ini)
# Top-level exclude names for a project's archive (mirrors Get-ArchiveExcludes).
# Usage: z_archive_excludes <key> [for_backup:0|1] -> one name per line
z_archive_excludes() {
local key="$1" for_backup="${2:-0}" kind
kind="$(zproj "$key" .kind)"
printf '%s\n' .git .idea .vscode .claude tmp nul .DS_Store backups
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 ;;
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"
}
# Build a zip from a source directory.
# Usage: z_archive <src_dir> <dest_zip> <include_scripts:0|1> <extra_file|-> [top_excludes...]
z_archive() {
local src="$1" dest="$2" include_scripts="$3" extra="$4"; shift 4
z_require zip
[ -d "$src" ] || { err "Source path is not a directory: $src"; return 1; }
rm -f "$dest"; mkdir -p "$(dirname "$dest")"
local pat=() e n
for n in "$@"; do [ -n "$n" ] && pat+=("$n" "$n/*"); done
for n in "${_Z_JUNK_DIRS[@]}"; do pat+=("$n/*" "*/$n/*"); done
for e in "${_Z_JUNK_EXTS[@]}"; do pat+=("*.${e}"); done
if [ "$include_scripts" -ne 1 ]; then
for e in "${_Z_SCRIPT_EXTS[@]}"; do pat+=("*.${e}"); done
fi
for n in "${_Z_JUNK_NAMES[@]}"; do pat+=("$n" "*/$n"); done
( cd "$src" && zip -rq "$dest" . -x "${pat[@]}" ) || { err "zip failed for $src"; return 1; }
if [ -n "$extra" ] && [ "$extra" != "-" ] && [ -f "$extra" ]; then
zip -jq "$dest" "$extra" || warn " could not add extra file: $extra"
fi
dim " Archive: $dest ($(z_size_mb "$dest") MB)"
}
# File size in MB (portable: bytes via wc).
z_size_mb() { awk -v b="$(wc -c < "$1" 2>/dev/null || echo 0)" 'BEGIN{printf "%.2f", b/1048576}'; }
# Percent-decode a URL component (for DATABASE_URL passwords).
z_urldecode() { local d="${1//+/ }"; printf '%b' "${d//%/\\x}"; }
# ---- port operations (Linux/macOS: needs lsof) ------------------------------
# Kill processes LISTENING on a TCP port. Progress -> stderr; killed count -> stdout.
kill_listeners() {
local port="$1" pids pid n=0
pids="$(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null | sort -u)"
for pid in $pids; do
dim " Killing PID $pid (port $port)" >&2
if kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null; then n=$((n + 1)); fi
done
printf '%s' "$n"
}

59
bash/zkill Normal file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# 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.
#
# zkill — stop local dev-server listeners for a project (bash port of ZKillOnly.ps1).
#
# Usage:
# zkill <project> [<project> ...] [--port N] [--kill-all]
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require lsof
projects=(); port_override=0; kill_all=0
while [ $# -gt 0 ]; do
case "$1" in
-p|--port) port_override="${2:-0}"; shift 2 ;;
--kill-all) kill_all=1; shift ;;
--) shift; break ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
if [ "${#projects[@]}" -eq 0 ]; then
warn "Usage: zkill <project> [<project> ...] [--port N] [--kill-all]"
dim " Projects in zconfig.json: $(zproj_csv)"
exit 1
fi
for key in "${projects[@]}"; do
zproj_require "$key"
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
if [ "$port_override" -gt 0 ] 2>/dev/null; then
port="$port_override"
else
port="$(zproj "$key" .ports.dev)"
fi
printf '\n'; info "=== zkill ($label) ==="
if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -gt 0 ]; then
dim "Port: $port"
killed="$(kill_listeners "$port")"
else
warn "No dev port configured for '$key' - skipping port kill."
killed=0
fi
# TODO (parity with ZKillOnly.ps1 -KillAll): also stop stray project processes.
if [ "${killed:-0}" -gt 0 ]; then
note " $killed process(es) stopped"
else
ok " Nothing to kill"
fi
done
printf '\n'; info "Kill complete."

79
bash/zrepair Normal file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# 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.
#
# zrepair — audit and repair container/proxy routing on the server, then smoke test.
#
# Usage:
# zrepair <project> [<project> ...]
#
# For each project: shows compose status, starts the stack if it's down,
# validates the edge proxy's nginx config (once, if an edge project is defined),
# and smoke-tests the live domain.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require ssh curl
if [ $# -eq 0 ]; then
printf '\n'; warn "Usage: zrepair <project> [<project> ...]"
dim " Projects in zconfig.json: $(zproj_csv)"
exit 1
fi
HOST="$(zec2_ip)"
printf '\n'; info "=== Routing repair & diagnostics ==="
dim "Target host: $HOST"
# Validate the edge proxy config once up front, if one is defined.
EDGE_KEY="$(zedge_key)"
if [ -n "$EDGE_KEY" ]; then
pc="$(zproj "$EDGE_KEY" .proxyContainer)"
if [ -n "$pc" ]; then
printf '\n'; warn " [edge] Validating nginx config in proxy container '$pc'..."
zec2_step "nginx -t in $pc" "sudo docker exec $pc nginx -t 2>&1" || exit 1
fi
fi
for key in "$@"; do
zproj_require "$key"
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
compose_dir="$(zremote_compose_dir "$key")"
domain="$(zproj "$key" .domain)"
printf '\n'
info "========================================="
info "=== Repairing $label ($key) ==="
info "========================================="
printf '\n'
warn " [1/3] Compose status on the server..."
zec2_step "Check compose status" \
"cd $compose_dir || exit 1; echo 'Running containers:'; sudo docker compose ps" || exit 1
warn " [2/3] Ensuring stack is up..."
zec2_step "Ensure stack is up" \
"cd $compose_dir || exit 1; if ! sudo docker compose ps | grep -q Up; then echo ' [Action] Stack is down. Starting...'; sudo docker compose up -d; else echo ' [OK] Stack is active and UP.'; fi" || exit 1
if [ -n "$domain" ]; then
warn " [3/3] Smoke test for https://$domain..."
code="$(http_code "http://$HOST/" "$domain")"
if [ "$code" = "000" ]; then
err " FAIL - smoke test failed: no HTTP response"
elif [ "$code" -lt 400 ]; then
ok " PASS - responded with HTTP $code (Online)"
elif [ "$code" -lt 500 ]; then
ok " PASS - responded with HTTP $code (Online/Redirect or auth)"
else
warn " WARNING - unexpected HTTP $code"
fi
else
warn " [3/3] No domain configured - skipping smoke test."
fi
done
printf '\n'; ok "=== Repair & diagnostics completed ==="
printf '\n'

53
bash/zrestart Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 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.
#
# zrestart — kill then restart dev servers for any project in zconfig.json.
#
# Usage:
# zrestart <project> [<project> ...] [--port N] [--kill-all] [--no-restart] [--detached] [--bind-host host]
set -uo pipefail
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_HERE/zhelpers.sh"
z_need_config
projects=(); port_override=0; kill_all=0; no_restart=0; detached=0; bind_host="127.0.0.1"
while [ $# -gt 0 ]; do
case "$1" in
-p|--port) port_override="${2:-0}"; shift 2 ;;
--kill-all) kill_all=1; shift ;;
--no-restart) no_restart=1; shift ;;
-d|--detached) detached=1; shift ;;
--bind-host) bind_host="${2:-127.0.0.1}"; shift 2 ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
if [ "${#projects[@]}" -eq 0 ]; then
printf '\n'; warn "Usage: zrestart <project> [<project> ...] [--port N] [--kill-all] [--no-restart] [--detached] [--bind-host host]"
dim " Projects in zconfig.json: $(zproj_csv)"
exit 1
fi
printf '\n'; info "=== zrestart ==="
for key in "${projects[@]}"; do
info "--- $key ---"
kill_args=("$key")
[ "$port_override" -gt 0 ] 2>/dev/null && kill_args+=(--port "$port_override")
[ "$kill_all" -eq 1 ] && kill_args+=(--kill-all)
"$_HERE/zkill" "${kill_args[@]}" || exit $?
if [ "$no_restart" -eq 0 ]; then
sleep 1
start_args=("$key")
[ "$port_override" -gt 0 ] 2>/dev/null && start_args+=(--port "$port_override")
[ "$bind_host" != "127.0.0.1" ] && start_args+=(--bind-host "$bind_host")
[ "$detached" -eq 1 ] && start_args+=(--detached)
"$_HERE/zstart" "${start_args[@]}" || exit $?
fi
done

106
bash/zsetup_mail Normal file
View File

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# 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.
#
# zsetup_mail — create admin@ and noreply@ mailboxes in a docker-mailserver
# container on the server, and print the DNS records + SMTP/IMAP settings to use.
#
# Usage:
# zsetup_mail --domain yourdomain.com [--mail-host mail.yourdomain.com] [--host <ip>] [--pem <path>]
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require ssh
domain=""; mail_host=""; ec2_host=""; pem=""
while [ $# -gt 0 ]; do
case "$1" in
--domain) domain="${2:-}"; shift 2 ;;
--mail-host) mail_host="${2:-}"; shift 2 ;;
--host) ec2_host="${2:-}"; shift 2 ;;
--pem) pem="${2:-}"; shift 2 ;;
*) err "Unknown option: $1"; exit 1 ;;
esac
done
[ -n "$ec2_host" ] || ec2_host="$(zec2_ip)"
[ -n "$pem" ] || pem="$(zec2_pem)"
if [ -z "$domain" ]; then
printf '\n'; warn "Usage: zsetup_mail --domain yourdomain.com [--mail-host mail.yourdomain.com]"
dim " Creates admin@ and noreply@ mailboxes on the server's mailserver container."
exit 1
fi
[ -n "$mail_host" ] || mail_host="mail.$domain"
info "=== Mail Setup Utility ==="
[ -f "$pem" ] || { err "PEM key not found: $pem"; exit 1; }
dim "Using PEM Key: $pem"
dim "Target Host : $ec2_host"
dim "Domain Name : $domain"
gen_password() {
LC_ALL=C tr -dc 'a-zA-Z0-9!@#%^&*' < /dev/urandom | head -c 16
}
admin_pw="$(gen_password)"
noreply_pw="$(gen_password)"
remote() {
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no -i "$pem" "$(zec2_user)@$ec2_host" "$1"
}
printf '\n'; warn "[1/3] Connecting to the mail server to provision mailboxes..."
dim "Adding email account: admin@$domain..."
if remote "sudo docker exec mailserver setup email add admin@$domain '$admin_pw'"; then
ok "Account admin@$domain added successfully."
else
warn "Could not add admin account (it might already exist or docker setup failed)."
fi
dim "Adding email account: noreply@$domain..."
if remote "sudo docker exec mailserver setup email add noreply@$domain '$noreply_pw'"; then
ok "Account noreply@$domain added successfully."
else
warn "Could not add noreply account (it might already exist or docker setup failed)."
fi
printf '\n'; warn "[2/3] DNS Configuration Requirements"
dim "Add or update the following records in your DNS zone for ${domain}:"
info "--------------------------------------------------------------------------------"
printf '%s\n' "1. MX Record (Inbound mail routing):"
ok " - Name : (leave blank or @)"
ok " - Type : MX"
warn " - Value : 10 $mail_host"
printf '%s\n' "2. TXT Record (SPF authorization):"
ok " - Name : (leave blank or @)"
ok " - Type : TXT"
warn " - Value : \"v=spf1 mx ~all\""
printf '%s\n' "3. A Record for Webmail:"
ok " - Name : webmail"
ok " - Type : A"
warn " - Value : $ec2_host"
printf '%s\n' "4. A Record for Mail Admin:"
ok " - Name : mail-admin"
ok " - Type : A"
warn " - Value : $ec2_host"
info "--------------------------------------------------------------------------------"
printf '\n'; warn "[3/3] Application Connection Credentials"
dim "Use these connection settings in your app config or .env:"
info "--------------------------------------------------------------------------------"
printf '%s\n' "SMTP Hostname : $mail_host"
printf '%s\n' "SMTP Port (STARTTLS) : 587"
printf '%s\n' "SMTP Port (SSL/TLS) : 465"
printf '%s\n' "IMAP Hostname : $mail_host"
printf '%s\n' "IMAP Port (SSL/TLS) : 993"
info "--------------------------------------------------------------------------------"
ok "Email User 1 : admin@$domain"
warn "Password : $admin_pw"
printf '\n'
ok "Email User 2 : noreply@$domain"
warn "Password : $noreply_pw"
info "--------------------------------------------------------------------------------"
ok "Completed successfully!"

172
bash/zstart Normal file
View File

@ -0,0 +1,172 @@
#!/usr/bin/env bash
# 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.
#
# zstart — start local dev servers for any project defined in zconfig.json.
#
# 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).
# Detached servers log to /tmp/zstart-<project>.log ; stop them with zkill.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
projects=(); port_override=0; bind_host="127.0.0.1"; detached=0
while [ $# -gt 0 ]; do
case "$1" in
-p|--port) port_override="${2:-0}"; shift 2 ;;
--bind-host) bind_host="${2:-127.0.0.1}"; shift 2 ;;
-d|--detached) detached=1; shift ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
if [ "${#projects[@]}" -eq 0 ]; then
printf '\n'; warn "Usage: zstart <project> [<project> ...] [--port N] [--detached] [--bind-host host]"
dim " Projects in zconfig.json: $(zproj_csv)"
exit 1
fi
warn_if_privileged_port() { # <port>
if [ "$1" -lt 1024 ] && [ "$(id -u)" -ne 0 ]; then
err "WARNING: Port $1 requires root privileges to bind. Run with sudo!"
fi
}
detach() { # <key> <workdir> <cmd...>
local key="$1" dir="$2"; shift 2
local log="/tmp/zstart-${key}.log"
( cd "$dir" && nohup "$@" >"$log" 2>&1 & )
ok "Started in detached mode (logs: $log)."
dim "Use zkill $key to stop it."
}
# Optional per-project pre-start steps from zconfig.json:
# "start": { "gitPull": true, "env": { "SOME_FLAG": "1" } }
start_prep() { # <key>
local key="$1" root kv name val
root="$(zproj "$key" .localRoot)"
while IFS=$'\t' read -r name val; do
[ -n "$name" ] || continue
export "$name=$val"
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."
fi
}
start_python() { # <key> <port>
local key="$1" port="$2" root module exe bv=""
root="$(zproj "$key" .localRoot)"
module="$(zproj "$key" .startModule)"
[ -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; }
# Prefer the project venv (Unix layout; Scripts/ fallback covers Windows-made venvs).
if [ -x "$root/.venv/bin/python" ]; then exe="$root/.venv/bin/python"
elif [ -x "$root/.venv/Scripts/python.exe" ]; then exe="$root/.venv/Scripts/python.exe"
elif command -v python3 >/dev/null 2>&1; then exe="python3"
else exe="python"; fi
# Optional convention: scripts/build_version_tool.py bumps the version on dev start.
if [ -f "$root/scripts/build_version_tool.py" ]; then
bv="$(cd "$root" && "$exe" scripts/build_version_tool.py bump 2>/dev/null | tail -1)"
[ -n "$bv" ] || bv="$(cd "$root" && "$exe" scripts/build_version_tool.py get 2>/dev/null | tail -1)"
fi
printf '\n'; info "=== zstart ($(zproj "$key" .label)) ==="
info "Starting python -m $module on port $port..."
[ -n "$bv" ] && note "Build Version: $bv"
show_project_motd "$root"
dim "Press Ctrl+C to stop the server"
warn_if_privileged_port "$port"
printf '\n'
if [ "$detached" -eq 1 ]; then
detach "$key" "$root" "$exe" -m "$module"
else
( cd "$root" && exec "$exe" -m "$module" )
fi
}
start_vite() { # <key> <port>
local key="$1" port="$2" root
root="$(zproj "$key" .localRoot)"
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
[ -f "$root/package.json" ] || { err "package.json not found in $root"; return 1; }
printf '\n'; info "=== zstart ($(zproj "$key" .label) - Vite) ==="
dim "Directory: $root"
info "URL: http://${bind_host}:${port}/"
printf '\n'
if [ ! -d "$root/node_modules" ]; then
warn "node_modules missing - running npm install..."
(cd "$root" && npm install) || { err "npm install failed"; return 1; }
fi
show_project_motd "$root"
dim "Press Ctrl+C to stop the dev server"
warn_if_privileged_port "$port"
printf '\n'
if [ "$detached" -eq 1 ]; then
detach "$key" "$root" npm run dev -- --host "$bind_host" --port "$port"
else
( cd "$root" && exec npm run dev -- --host "$bind_host" --port "$port" )
fi
}
start_next() { # <key> <port>
local key="$1" port="$2" root
root="$(zproj "$key" .localRoot)"
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
[ -f "$root/package.json" ] || { err "package.json not found in $root"; return 1; }
printf '\n'; info "=== zstart ($(zproj "$key" .label) - Next.js) ==="
info "Starting on port $port..."
printf '\n'
show_project_motd "$root"
dim "Press Ctrl+C to stop the server"
warn_if_privileged_port "$port"
printf '\n'
export PORT="$port"
if [ "$detached" -eq 1 ]; then
detach "$key" "$root" npm run dev
else
( cd "$root" && exec npm run dev )
fi
}
rc=0
for key in "${projects[@]}"; do
zproj_require "$key"
kind="$(zproj "$key" .kind)"
if [ "$port_override" -gt 0 ] 2>/dev/null; then port="$port_override"
else port="$(zproj "$key" .ports.dev)"; [[ "$port" =~ ^[0-9]+$ ]] || port=3000; fi
start_prep "$key"
case "$kind" in
python) start_python "$key" "$port" || rc=1 ;;
vite) start_vite "$key" "$port" || rc=1 ;;
nextjs) start_next "$key" "$port" || rc=1 ;;
*)
printf '\n'
warn "Project '$key' has kind '$kind' - no local dev server to start."
dim "Supported kinds for zstart: python, vite, nextjs"
;;
esac
done
exit "$rc"

65
bash/zstart_docker Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# 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.
#
# zstart_docker — bring up a local docker compose stack from <scriptsRoot>/docker/.
#
# Usage:
# zstart_docker [--build] [--attached] [--solo]
#
# --build rebuild images before starting
# --attached stream logs in the foreground (default is detached)
# --solo use docker-compose.solo.yml (app only, no proxy)
set -uo pipefail
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_HERE/zhelpers.sh"
z_require docker
build=0; attached=0; solo=0
while [ $# -gt 0 ]; do
case "$1" in
--build) build=1; shift ;;
--attached) attached=1; shift ;;
--solo) solo=1; shift ;;
*) err "Unknown option: $1"; exit 1 ;;
esac
done
info "=== zstart_docker ==="
compose_file="docker-compose.yml"
[ "$solo" -eq 1 ] && compose_file="docker-compose.solo.yml"
if ! docker info >/dev/null 2>&1; then
printf '\n'
err "ERROR: Docker Engine is not running or this shell cannot reach it."
printf '\n'
info "Try:"
dim " 1. Start the Docker daemon (systemctl start docker / open Docker Desktop)."
dim " 2. Check your user is in the docker group: groups | grep docker"
printf '\n'
exit 1
fi
compose_path="$_HERE/docker/$compose_file"
if [ ! -f "$compose_path" ]; then
err "ERROR: Missing $compose_path"
exit 1
fi
[ -f "$_HERE/.env" ] || warn "WARNING: .env not found at $_HERE/.env - compose may still start if env vars are set elsewhere."
dc_args=(compose -f "$compose_file" up)
[ "$attached" -eq 0 ] && dc_args+=(-d)
[ "$build" -eq 1 ] && dc_args+=(--build)
dim "Running: docker ${dc_args[*]}"
(cd "$_HERE/docker" && docker "${dc_args[@]}") || exit $?
printf '\n'; ok "Stack: http://localhost/"
if [ "$attached" -eq 0 ]; then
dim 'Logs: docker compose -f docker/docker-compose.yml logs -f'
dim 'Stop: docker compose -f docker/docker-compose.yml down'
fi

37
bash/zstop Normal file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# 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.
#
# zstop — stop docker compose stacks on the server without removing data or files.
#
# Usage:
# zstop <project> [<project> ...]
#
# Data volumes are preserved. Run zdeploy <project> to bring a stack back up.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
z_require ssh
if [ $# -eq 0 ]; then
printf '\n'; warn "Usage: zstop <project> [<project> ...]"
dim " Projects in zconfig.json: $(zproj_csv)"
exit 1
fi
for key in "$@"; do
zproj_require "$key"
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
compose_dir="$(zremote_compose_dir "$key")"
printf '\n'; info "--- Stopping $label ---"
if zssh "cd $compose_dir && sudo docker compose down 2>&1 || true"; then
ok " $label stopped."
else
warn " WARNING: docker compose down returned non-zero for $label"
fi
done
printf '\n'; dim "Done. Data volumes are intact. Run zdeploy <project> to restart."

96
bash/zsync Normal file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# 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.
#
# zsync — copy new backup files offsite; or build + mirror a vite project's dist.
#
# Usage:
# zsync # new files: backups folder -> paths.oneDriveBackups
# zsync <viteproject> --dest /mnt/mirror/dist
#
# The no-args mode copies only files that don't exist at the destination yet
# (never overwrites, never deletes). The project mode runs npm run build, then
# mirrors dist/ to --dest (or $ZSYNC_DEST) with rsync --delete.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_need_config
projects=(); dest="${ZSYNC_DEST:-}"
while [ $# -gt 0 ]; do
case "$1" in
--dest) dest="${2:-}"; shift 2 ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
# ---- no-args mode: backups -> offsite, new files only ------------------------
if [ "${#projects[@]}" -eq 0 ]; then
BACKUPS_ROOT="$(dirname "$(zq '.paths.backupsLocal')")"
OFFSITE_ROOT="$(zq '.paths.oneDriveBackups')"
printf '\n'; info "=== zsync (backups -> offsite) ==="
dim " Source: $BACKUPS_ROOT"
dim " Destination: $OFFSITE_ROOT"
dim " Mode: new files and folders only"
printf '\n'
if [ -z "$OFFSITE_ROOT" ]; then
warn " paths.oneDriveBackups is not set in zconfig.json - nothing to do."
exit 0
fi
if [ ! -d "$BACKUPS_ROOT" ]; then
warn " Source not found (skipped): $BACKUPS_ROOT"
exit 0
fi
mkdir -p "$OFFSITE_ROOT"
copied=0; skipped=0
while IFS= read -r -d '' f; do
rel="${f#"$BACKUPS_ROOT"/}"
target="$OFFSITE_ROOT/$rel"
if [ -e "$target" ]; then skipped=$((skipped + 1)); continue; fi
mkdir -p "$(dirname "$target")"
cp -p "$f" "$target"
ok " Copied: $rel ($(z_size_mb "$f") MB)"
copied=$((copied + 1))
done < <(find "$BACKUPS_ROOT" -type f -print0 2>/dev/null)
printf '\n'; info " Done: $copied copied, $skipped already present"
printf '\n'
exit 0
fi
# ---- project mode: build + mirror a vite project's dist ----------------------
z_require rsync
for key in "${projects[@]}"; do
zproj_require "$key"
kind="$(zproj "$key" .kind)"
if [ "$kind" != "vite" ]; then
printf '\n'; warn "zsync project mode only supports vite kind (builds and mirrors dist/). '$key' is kind '$kind'."
exit 1
fi
root="$(zproj "$key" .localRoot)"
if [ -z "$dest" ]; then
printf '\n'; info "=== zsync ($(zproj "$key" .label)) ==="
warn "No destination set."
dim "Set ZSYNC_DEST or run: zsync $key --dest /path/to/folder"
dim "This runs npm run build, then mirrors dist -> destination (rsync --delete)."
exit 1
fi
printf '\n'; info "=== zsync ($(zproj "$key" .label)) ==="
info "Build + mirror dist -> $dest"
printf '\n'
(cd "$root" && npm run build) || { err "npm run build failed"; exit 1; }
[ -d "$root/dist" ] || { err "dist/ missing after build."; exit 1; }
mkdir -p "$dest"
rsync -a --delete "$root/dist/" "$dest/" || { err "rsync failed"; exit 1; }
printf '\n'; ok "Sync complete."
done