From 09f86c1cba867b5425fc04c6dbf0d12d01b4a6fd Mon Sep 17 00:00:00 2001 From: kellymichels Date: Wed, 22 Jul 2026 16:35:05 -0500 Subject: [PATCH] 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-.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). --- .gitattributes | 6 + README.md | 17 +- bash/README.md | 86 +++++++ bash/setup_backup_schedule | 51 ++++ bash/zbackup | 132 ++++++++++ bash/zbackup_and_sync | 48 ++++ bash/zbackup_ec2 | 100 ++++++++ bash/zconfig.example.json | 85 +++++++ bash/zdeploy | 495 +++++++++++++++++++++++++++++++++++++ bash/zec2 | 94 +++++++ bash/zec2online | 149 +++++++++++ bash/zhelpers.sh | 313 +++++++++++++++++++++++ bash/zkill | 60 +++++ bash/zrepair | 80 ++++++ bash/zrestart | 54 ++++ bash/zsetup_mail | 107 ++++++++ bash/zstart | 176 +++++++++++++ bash/zstart_docker | 66 +++++ bash/zstop | 38 +++ bash/zsync | 97 ++++++++ 20 files changed, 2247 insertions(+), 7 deletions(-) create mode 100644 .gitattributes create mode 100644 bash/README.md create mode 100644 bash/setup_backup_schedule create mode 100644 bash/zbackup create mode 100644 bash/zbackup_and_sync create mode 100644 bash/zbackup_ec2 create mode 100644 bash/zconfig.example.json create mode 100644 bash/zdeploy create mode 100644 bash/zec2 create mode 100644 bash/zec2online create mode 100644 bash/zhelpers.sh create mode 100644 bash/zkill create mode 100644 bash/zrepair create mode 100644 bash/zrestart create mode 100644 bash/zsetup_mail create mode 100644 bash/zstart create mode 100644 bash/zstart_docker create mode 100644 bash/zstop create mode 100644 bash/zsync diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4dc0456 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Bash scripts must stay LF — CRLF breaks the shebang on Linux/macOS/WSL. +bash/** text eol=lf + +# PowerShell/cmd are happiest with CRLF on Windows. +*.ps1 text eol=crlf +*.cmd text eol=crlf diff --git a/README.md b/README.md index 357195f..2e38dc3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Every command is a tiny PowerShell script driven by a single JSON config file. T **Requirements:** Windows, PowerShell 5.1+, OpenSSH client (`ssh`/`scp`, ships with Windows 10/11), and Docker + docker compose on the remote host for the deploy scripts. +**Linux / macOS / WSL:** a native bash port of every command lives in [`bash/`](bash/README.md) — same config schema, same commands, no PowerShell needed. + --- ## Install @@ -120,7 +122,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more | `zec2 [ ...]` | Quick reachability check (TCP + HTTP + live build version) | | `zec2online [ ...]` | Deep health check; auto-starts downed stacks, streams diagnostics | | `zrepair ...` | Audit + repair compose/proxy state on the server | -| `zbackup [ ...]` | Zip local project sources (+ DB dump) to the backups folder | +| `zbackup ... \| all` | Zip local project sources (+ DB dump) to the backups folder | | `zbackup_ec2 [ ...]` | Pull DB dumps + server-side data files down from the server | | `zsync []` | Copy new backups offsite (or build + mirror a vite dist) | | `zstart_docker` | Run a local docker compose stack from `scriptsRoot\docker\` | @@ -224,14 +226,15 @@ zstop [ ...] #### `zbackup` — local backups ``` -zbackup [ ...] [-Tag "label"] # no args = every project + this scripts folder +zbackup [ ...] [-Tag "label"] +zbackup all # every project + this scripts folder zbackup scripts # just this scripts folder ('scripts' is reserved) ``` -Zips each project's source into `paths.backupsLocal\\_[_tag].zip`. If the project's `.env` declares a `DATABASE_URL`, a Postgres dump is bundled into the zip automatically (`backend\.env` is checked too, for frontend/backend split projects). `-Tag` labels the archive — handy before risky changes. +Zips each project's source into `paths.backupsLocal\\_[_tag].zip`. If the project's `.env` declares a `DATABASE_URL`, a Postgres dump is bundled into the zip automatically — quoted values (Prisma-style), `postgres://`/`postgresql+driver://` schemes, and URLs without an explicit port all parse. `backend\.env` is checked too, for frontend/backend split projects. `-Tag` labels the archive — handy before risky changes. ```powershell -zbackup # everything +zbackup all # everything zbackup pyapp -Tag "pre-migration" ``` @@ -255,14 +258,14 @@ The no-args mode copies only files that don't already exist at the destination ( #### `zbackup_and_sync.ps1` — both in one ``` -zbackup_and_sync.ps1 [ ...] +zbackup_and_sync.ps1 [ ...] | all ``` -Runs `zbackup`, then `zsync`. This is what the scheduled task calls. +Runs `zbackup`, then `zsync`. This is what the scheduled task calls (with `all`). #### `setup_backup_schedule.ps1` — nightly automation -Run **as Administrator** once. Creates a Windows Scheduled Task that runs `zbackup_and_sync.ps1` daily at 2:00 AM. +Run **as Administrator** once. Creates a Windows Scheduled Task that runs `zbackup_and_sync.ps1 all` daily at 2:00 AM. ### Utilities diff --git a/bash/README.md b/bash/README.md new file mode 100644 index 0000000..5ba9e09 --- /dev/null +++ b/bash/README.md @@ -0,0 +1,86 @@ + + +# 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 ` 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

[--detached]` | start local dev server (python/vite/nextjs) | +| `zkill

` | free the project's dev port | +| `zrestart

[--detached]` | kill + start in one command | +| `zstop

` | stop the project's compose stack on the server | +| `zdeploy

[--note "msg"]` | zip → scp → docker compose build/up → verify build version | +| `zec2 [

]` | TCP + HTTP + live-version reachability check | +| `zec2online [

]` | deep health check; auto-starts downed stacks | +| `zrepair

` | audit/repair container + proxy routing, smoke test | +| `zbackup [

] [--tag t]` | local source zip (+ pg_dump when .env has DATABASE_URL) | +| `zbackup_ec2 [

]` | 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-.log`. + +## Usage tracking + +Every run prints a footer with its own output volume: + +``` +--- 1,048 lines / 69,009 chars / ~19,717 tokens est. (Claude Code) --- +``` + +and (when `jq` is available) appends one JSON line per run to `tokens.jsonl`, +so you can see how much infrastructure output you keep out of an AI agent's +context over time. Fields: `ts, script, projects, lines, chars, est, model` +(`est` ≈ `chars / 3.5`). Nested runs (the `zkill`/`zstart` inside `zrestart`) +are counted once, not double. + +Where it's written, first match wins: + +1. `$ZTOKENS_DATA` +2. `ztokens.dataDir` in `zconfig.json` +3. the sibling `../ztokens/data` directory, if present +4. `~/.ztokens/data` (created on first run) + +Set `ZTOKENS_MODEL` to tag records with a specific model; it defaults to +`est. chars/3.5`. + +## Differences from the PowerShell versions + +- `zsync ` 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. diff --git a/bash/setup_backup_schedule b/bash/setup_backup_schedule new file mode 100644 index 0000000..e15f0d7 --- /dev/null +++ b/bash/setup_backup_schedule @@ -0,0 +1,51 @@ +#!/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" +z_track_start "$@" + +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 diff --git a/bash/zbackup b/bash/zbackup new file mode 100644 index 0000000..70f2088 --- /dev/null +++ b/bash/zbackup @@ -0,0 +1,132 @@ +#!/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 [ ...] +# zbackup scripts # just this scripts folder ('scripts' is a reserved word) +# zbackup pyapp --tag "pre-migration" +# +# Output: //_[_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 /.env, then /backend/.env. Returns 0 and writes $2 on success. +local_pg_dump() { # + 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() { # + 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" diff --git a/bash/zbackup_and_sync b/bash/zbackup_and_sync new file mode 100644 index 0000000..497c37b --- /dev/null +++ b/bash/zbackup_and_sync @@ -0,0 +1,48 @@ +#!/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 [ ...] +# +# 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_track_start "$@" +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 diff --git a/bash/zbackup_ec2 b/bash/zbackup_ec2 new file mode 100644 index 0000000..828c047 --- /dev/null +++ b/bash/zbackup_ec2 @@ -0,0 +1,100 @@ +#!/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 [ ...] +# +# Output: +# //__db.sql (projects with a db block) +# //__files.zip + +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh" +z_track_start "$@" +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() { # + 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() { # + 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" diff --git a/bash/zconfig.example.json b/bash/zconfig.example.json new file mode 100644 index 0000000..d0f018b --- /dev/null +++ b/bash/zconfig.example.json @@ -0,0 +1,85 @@ +{ + "_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": "" + }, + + "ztokens": { + "_comment": "Optional. Where each run's token-usage stats (tokens.jsonl) are recorded. Omit to use ~/.ztokens/data.", + "dataDir": "/home/youruser/.ztokens/data" + }, + + "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" } + } + } +} diff --git a/bash/zdeploy b/bash/zdeploy new file mode 100644 index 0000000..3e09192 --- /dev/null +++ b/bash/zdeploy @@ -0,0 +1,495 @@ +#!/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 [ ...] [--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_track_start "$@" +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 [ ...] | 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() { # + 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() { # + 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() { #