zscripts-token-savers/bash/zbackup
kellymichels 09f86c1cba
feat(bash): native bash port of all z-scripts for Linux/macOS/WSL (#5)
* 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.

* chore: pin line endings (.gitattributes) - bash LF, powershell CRLF

* docs(readme): point Linux/macOS/WSL users at the bash port

* fix(bash): don't run the Windows venv python.exe on WSL/Linux/macOS

zstart's venv detection fell back to .venv/Scripts/python.exe (a Windows
binary) whenever it existed. On a Windows-built project accessed from WSL that
file sits on the mount and looks executable, so it got picked and failed with
'exec format error' instead of falling through to python3. Guard that branch to
Windows-family shells (msys/cygwin), where a .exe can actually run.

Verified on WSL: zstart --detached now backgrounds a stdlib app via python3,
serves HTTP 200, logs to /tmp/zstart-<key>.log, and zkill terminates it and
frees the port.

* feat(bash): add token-usage tracking to the bash port (#6)

Adds z_track_start/z_track_stop + z_record to zhelpers.sh and wires
z_track_start into every command script. Each run now captures its own output
volume (FIFO+tee, ANSI stripped), prints the '--- N lines / N chars / ~N tokens
est. (Claude Code) ---' footer, and appends one JSONL row per top-level run in
the same shape as the PowerShell tokens.jsonl. A nested-run guard keeps
zrestart from double-counting its zkill/zstart children.

Data dir precedence: $ZTOKENS_DATA, config ztokens.dataDir, sibling
../../ztokens/data, else ~/.ztokens/data. Docs + example config updated.

Verified on WSL (isolated data dir): single run records correctly; nested
zrestart produces one combined record, not three; est = round(chars/3.5).
2026-07-22 16:35:05 -05:00

133 lines
4.8 KiB
Bash

#!/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_track_start "$@"
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"