chore: hold the bash port back from the public repo until it is tested

The bash port and its bats suite are removed from the tree while they get more
testing. Everything they were referenced from is cleaned up so nothing dangles:

  - README drops the 'Linux / macOS / WSL' pointer to bash/README.md (a dead
    link once the folder is gone) and the aside that ZCONFIG is honoured by
    both ports.
  - CHANGELOG drops the two bash mentions.
  - ZHelpers.ps1's ZCONFIG comment no longer cites zhelpers.sh.
  - .gitattributes drops the now-dead bash/**, tests/bash/** and *.bats rules,
    keeping the PowerShell CRLF rules.

No behaviour change to the PowerShell scripts; the Pester suite is untouched
and still passes 169/169.

Deliberately NOT a history rewrite: the port stays in this repo's history and
in full in the private mirror, so it can be restored with a revert when the
testing is done. Nothing here is secret - it is unfinished, not sensitive.
This commit is contained in:
KellyMichels 2026-07-26 13:23:33 -05:00
parent 65c5886459
commit ddece471c9
25 changed files with 7 additions and 2977 deletions

9
.gitattributes vendored
View File

@ -1,12 +1,3 @@
# Bash scripts must stay LF — CRLF breaks the shebang on Linux/macOS/WSL.
bash/** text eol=lf
# The bats suites are bash too, and they live outside bash/ — without this a
# Windows checkout gets CRLF and `bats tests/bash` fails there (including when
# run through WSL against the Windows working copy).
tests/bash/** text eol=lf
*.bats text eol=lf
# PowerShell/cmd are happiest with CRLF on Windows. # PowerShell/cmd are happiest with CRLF on Windows.
*.ps1 text eol=crlf *.ps1 text eol=crlf
*.cmd text eol=crlf *.cmd text eol=crlf

View File

@ -28,9 +28,9 @@ Notable changes to the Evomedia.net Token Savers.
lookups, `remote.composeDir` fallback, EC2 target composition, and build-label lookups, `remote.composeDir` fallback, EC2 target composition, and build-label
formatting. Run with `Invoke-Pester .\tests` (Pester 5+). Verified by mutation formatting. Run with `Invoke-Pester .\tests` (Pester 5+). Verified by mutation
testing — reintroducing each historical bug turns the suite red. testing — reintroducing each historical bug turns the suite red.
- **`ZCONFIG` environment variable (PowerShell)** — overrides the path to - **`ZCONFIG` environment variable** — overrides the path to `zconfig.json`, so
`zconfig.json`, matching the bash port, which has always honored it. Closes a a run can target an alternate config. Also gives the test suite a seam for
parity gap and gives the test suite a seam for injecting a fixture config. injecting a fixture.
- **`zec2_rotatekeys` — safely rotate/reset server-side secrets** — a new - **`zec2_rotatekeys` — safely rotate/reset server-side secrets** — a new
tool for when a secret leaks or a deploy overwrites a production `.env` tool for when a secret leaks or a deploy overwrites a production `.env`
with dev values. `-Rotate KEY` regenerates a key **on the server** with dev values. `-Rotate KEY` regenerates a key **on the server**
@ -46,8 +46,7 @@ Notable changes to the Evomedia.net Token Savers.
- **`zkill all`** — `zkill` now accepts `all`, stopping the dev server of - **`zkill all`** — `zkill` now accepts `all`, stopping the dev server of
every project that has a `ports.dev` (edge/docker stacks with no local dev every project that has a `ports.dev` (edge/docker stacks with no local dev
server are skipped). Brings it in line with `zdeploy all` / `zbackup all`; server are skipped). Brings it in line with `zdeploy all` / `zbackup all`;
the one-shot "stop everything I've got running locally". Ported to both the the one-shot "stop everything I've got running locally".
PowerShell and bash versions.
- **`zdeploy` server-side health verification (`verify` block)** — projects - **`zdeploy` server-side health verification (`verify` block)** — projects
not published through the edge proxy can declare not published through the edge proxy can declare
`"verify": { "port": ..., "path": "/health", "expect": "..." }` and the `"verify": { "port": ..., "path": "/health", "expect": "..." }` and the

View File

@ -14,8 +14,6 @@ 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. **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 ## Install
@ -49,7 +47,7 @@ notepad zconfig.json
The example config ships with sample projects named by their kind — `pyapp`, `viteapp`, `nextapp`, `edge`, `analytics`. **Rename the keys to your own project names**; the key is what you type as the command argument. Add as many projects as you like — no script edits ever needed. The example config ships with sample projects named by their kind — `pyapp`, `viteapp`, `nextapp`, `edge`, `analytics`. **Rename the keys to your own project names**; the key is what you type as the command argument. Add as many projects as you like — no script edits ever needed.
Set the `ZCONFIG` environment variable to point at a config somewhere else (both the PowerShell scripts and the bash port honor it) — handy for a second machine profile, or for running against a scratch config without touching your real one. Set the `ZCONFIG` environment variable to point at a config somewhere else — handy for a second machine profile, or for running against a scratch config without touching your real one.
### Config reference ### Config reference

View File

@ -34,9 +34,8 @@ $script:JunkDirNames = @(
$script:ZConfigCache = $null $script:ZConfigCache = $null
# Where zconfig.json lives. Defaults to next to the scripts; override with the # Where zconfig.json lives. Defaults to next to the scripts; override with the
# ZCONFIG environment variable, matching the bash port (zhelpers.sh does the # ZCONFIG environment variable. Useful for pointing a run at an alternate
# same). Useful for pointing a run at an alternate config, and it is the seam # config, and it is the seam the test suite uses to inject a fixture.
# the test suite uses to inject a fixture.
function Get-ZConfigPath { function Get-ZConfigPath {
if ($env:ZCONFIG) { return $env:ZCONFIG } if ($env:ZCONFIG) { return $env:ZCONFIG }
return (Join-Path $PSScriptRoot "zconfig.json") return (Join-Path $PSScriptRoot "zconfig.json")

View File

@ -1,107 +0,0 @@
<!--
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 3.2+ (the stock macOS bash works), `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` (bash, curl, zip, ssh, lsof and rsync already ship with macOS)
## 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 |
|---|---|
| `zsetup <p>` | create the project's python venv + install deps (or `npm install`) |
| `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`.
## 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`.
## Tests
The bash port has its own [bats](https://github.com/bats-core/bats-core) suite, mirroring the PowerShell Pester tests assertion-for-assertion where the two are meant to agree — the exclude lists, config accessors, `z_path` translation, and argument handling:
```bash
bats tests/bash
```
Install bats without root:
```bash
git clone --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats-core
/tmp/bats-core/install.sh ~/.local # then ensure ~/.local/bin is on PATH
```
A fixture config is injected through `ZCONFIG`, so the suite never reads your real `zconfig.json` and passes on a machine that has never been configured. Tests that run the scripts stay on paths that exit before doing any work; only `zkill` runs with a real target, against deliberately unused ports.
Why a separate suite: the bash port reimplements the exclude lists and argument parsing, so it can drift from the PowerShell side independently. Both suites assert the same deploy-vs-backup rule — **deploys drop `.env*` and `uploads/`, backups keep them** — because that one has broken in production.
## 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.
- **A leading dash on a project key is only tolerated by `zdeploy`.** PowerShell accepts `-myapp` anywhere; in bash every other script treats `-myapp` as an unknown option and exits 1. Tracked as a parity gap — use bare keys in the bash port.

View File

@ -1,51 +0,0 @@
#!/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 all >> \$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

View File

@ -1,151 +0,0 @@
#!/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 <project> [<project> ...]
# zbackup all # every project in zconfig.json + this scripts folder
# zbackup scripts # just this scripts folder ('scripts' is a reserved word)
# zbackup pyapp --tag "pre-migration"
#
# 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="$(z_path "$(zq '.paths.backupsLocal')")"
# Require an explicit target: bare invocation shows usage instead of quietly
# backing up everything - 'all' is explicit, matching zdeploy.
if [ "${#projects[@]}" -eq 0 ]; then
printf '\n'; warn "Usage: zbackup <project> [<project> ...] | all | scripts [--tag \"label\"]"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' backs up every project plus this scripts folder; 'scripts' just this folder."
exit 1
fi
include_scripts=0
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys)
include_scripts=1
else
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. Prints its own status line for
# every outcome; returns 0 (dumped, $2 written) or 1 (nothing dumped).
# A not-running database (connection refused) is a calm, expected skip; a real
# failure (version mismatch, auth, missing db) prints the loud pg_dump error.
local_pg_dump() { # <root> <out_path>
local root="$1" out="$2" env_file db_url err_out rc
env_file="$root/.env"; [ -f "$env_file" ] || env_file="$root/backend/.env"
if [ ! -f "$env_file" ]; then dim " No local DATABASE_URL - source-only backup."; return 1; fi
db_url="$(grep -m1 '^DATABASE_URL=' "$env_file" | cut -d= -f2- | tr -d '[:space:]')"
if [ -z "$db_url" ]; then dim " No local DATABASE_URL - source-only backup."; return 1; fi
db_url="$(printf '%s' "$db_url" | sed -E 's|^postgresql\+[^:]+://|postgresql://|')"
if [[ "$db_url" =~ ^postgresql://([^:]+):([^@]+)@([^:]+):([0-9]+)/([^?]+) ]]; then
local user="${BASH_REMATCH[1]}" pass="${BASH_REMATCH[2]}" host="${BASH_REMATCH[3]}"
local port="${BASH_REMATCH[4]}" db="${BASH_REMATCH[5]}"
command -v pg_dump >/dev/null 2>&1 || { err ' pg_dump not installed - skipping PG backup'; return 1; }
err_out="$(PGPASSWORD="$(z_urldecode "$pass")" pg_dump -h "$host" -p "$port" -U "$user" -d "$db" -F p -f "$out" 2>&1)"; rc=$?
if [ "$rc" -eq 0 ] && [ -f "$out" ]; then
ok " PostgreSQL dump: $(awk -v b="$(wc -c < "$out")" 'BEGIN{printf "%.1f", b/1024}') KB"
return 0
fi
# Not reachable (usually just not running locally) is expected - stay calm.
if printf '%s' "$err_out" | grep -qiE 'connection refused|could not connect|no route to host|could not translate host|timeout expired'; then
dim " Local database not running at $host:$port - source-only backup."
return 1
fi
# A real failure (version mismatch, auth, missing db) - show the reason.
err " pg_dump failed ($host:$port/$db as $user):"
printf '%s\n' "$err_out" | grep -v '^[[:space:]]*$' | sed 's/^/ /' >&2
return 1
fi
err ' Could not parse DATABASE_URL - skipping PG backup'
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_root "$key")"
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"; fi
warn " [2/3] Archiving source..."
local excl=()
excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(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="$(z_path "$(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"

View File

@ -1,56 +0,0 @@
#!/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 <project> [<project> ...]
# zbackup_and_sync all # backup everything + sync
#
# Cron example (see setup_backup_schedule):
# 0 2 * * * /path/to/zscripts/bash/zbackup_and_sync all >> ~/zbackup.log 2>&1
set -uo pipefail
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_HERE/zhelpers.sh"
z_track_start "$@"
z_need_config
# Require an explicit target (matching zbackup/zdeploy); 'all' backs up everything.
if [ $# -eq 0 ]; then
printf '\n'; warn "Usage: zbackup_and_sync <project> [<project> ...] | all"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' backs up every project plus the scripts folder, then syncs offsite."
exit 1
fi
printf '\n'
info "============================================"
info " Backup & Sync - $(date '+%Y-%m-%d %H:%M:%S')"
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

View File

@ -1,108 +0,0 @@
#!/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 <project> [<project> ...]
# zbackup_ec2 all # every project with a remote.path
#
# 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_track_start "$@"
z_need_config
z_require ssh scp
projects=("$@")
# Require an explicit target; 'all' pulls every project with a remote.path.
if [ "${#projects[@]}" -eq 0 ]; then
printf '\n'; warn "Usage: zbackup_ec2 <project> [<project> ...] | all"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' pulls a server backup of every project with a remote.path."
exit 1
fi
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done \
< <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.remote.path != null) | .key' "$ZCONFIG")
fi
EC2_BACKUP_ROOT="$(z_path "$(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"

View File

@ -1,87 +0,0 @@
{
"_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",
"_startApp_note": "ASGI/FastAPI app? Use \"startApp\": \"app.main:app\" instead of startModule (runs uvicorn --port <dev> --reload).",
"install": "-e .",
"ports": { "dev": 8080 },
"domain": "pyapp.yourdomain.com",
"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" }
}
}
}

View File

@ -1,497 +0,0 @@
#!/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_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 <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="$(z_path "$(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
edge_keys=(); while IFS= read -r _zl || [ -n "$_zl" ]; do edge_keys+=("$_zl"); done \
< <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG")
rest_keys=(); while IFS= read -r _zl || [ -n "$_zl" ]; do rest_keys+=("$_zl"); done \
< <(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_root "$key")"
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_root "$key")"
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
excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(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_root "$key")"
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
excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(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_root "$key")"
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 ---"
excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(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_root "$key")"
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_root "$key")"
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

100
bash/zec2
View File

@ -1,100 +0,0 @@
#!/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 <project> [<project> ...]
# zec2 all # every project with a "domain" in zconfig.json
# zec2 viteapp --host 203.0.113.10
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_track_start "$@"
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
printf '\n'; warn "Usage: zec2 <project> [<project> ...] | all [--host <ip>]"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' checks every project with a 'domain' configured."
exit 1
fi
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys_with_domain)
if [ "${#projects[@]}" -eq 0 ]; then
warn "No projects with a 'domain' configured in zconfig.json."
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"

View File

@ -1,155 +0,0 @@
#!/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 <project> [<project> ...]
# zec2online all # every project with a "domain" in zconfig.json
#
# A plain HTTP-200 check passes even when a stale cached build is live — the
# local-vs-server version match is what proves the deployed build is the one running.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_track_start "$@"
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
printf '\n'; warn "Usage: zec2online <project> [<project> ...] | all [--host <ip>]"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' deep-checks every project with a 'domain' (and auto-starts any that are down)."
exit 1
fi
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys_with_domain)
if [ "${#projects[@]}" -eq 0 ]; then
warn "No projects with a 'domain' configured in zconfig.json."
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"

View File

@ -1,359 +0,0 @@
#!/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
}
# ---- local path translation -------------------------------------------------
# A shared zconfig.json may hold Windows paths (e.g. "F:\evomedia.net\app") when
# the same file is used from a Windows checkout and from WSL. Translate a
# drive-letter path to the host's native form so local file ops work; leave
# anything else (Unix paths, empty strings) untouched — so this is a no-op on
# native Linux/macOS. Only LOCAL paths get run through this; server-side paths
# (remote.path, composeDir, stackRoot, certsSource) stay verbatim.
z_path() {
local p="$1"
case "$p" in
[A-Za-z]:[\\/]*)
if command -v wslpath >/dev/null 2>&1; then
wslpath -u "$p" 2>/dev/null || printf '%s' "$p"
else
# Fallback when wslpath is absent: F:\a\b -> /mnt/f/a/b. Lowercase the
# drive via tr (not ${x,,}) so this stays bash-3.2 clean for macOS.
local drive rest
drive="$(printf '%s' "${p%%:*}" | tr '[:upper:]' '[:lower:]')"
rest="${p#*:}"; rest="${rest//\\//}"
printf '/mnt/%s%s' "$drive" "$rest"
fi
;;
*) printf '%s' "$p" ;;
esac
}
# ---- 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"; }
# A project's local root, translated to the host's native path form.
zproj_root() { z_path "$(zproj "$1" .localRoot)"; }
zproj_require() {
# Keys starting with "_" are comments, not projects (zproj_keys already skips
# them). Reject them here too, or `zkill _comment` would sail past this guard
# on the mere existence of the key and then act on a string as if it were a
# project - PowerShell's Get-ZProject has always refused them.
case "$1" in
_*) err "Unknown project key '$1'. Available: $(zproj_csv)"; exit 1 ;;
esac
if [ "$(jq -r --arg k "$1" '(.projects[$k] | type) == "object"' "$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() { z_path "$(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_root "$key")"
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
# deploys exclude user uploads + local .env secrets; backups keep both
[ "$for_backup" -eq 1 ] || printf '%s\n' uploads .env .env.local .env.production ;;
vite)
printf '%s\n' node_modules dist
[ "$for_backup" -eq 1 ] || printf '%s\n' .env .env.local .env.production ;;
nextjs) printf '%s\n' node_modules .next .env .env.local .env.production .vercel coverage out build next-env.d.ts ;;
esac
jq -r --arg k "$key" '.projects[$k].deploy.exclude // [] | .[]' "$ZCONFIG"
}
# 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"
}
# ---- output tracking (ztokens) ----------------------------------------------
# Mirrors the PowerShell Start-/Stop-ZTracking + tokens.jsonl feed: capture this
# script's own output volume, print the "--- N lines / N chars / ~N tokens ---"
# footer, and append one JSONL record per top-level run. Nested z-scripts (e.g.
# zkill/zstart under zrestart) inherit the capture and do NOT double-count.
# Call `z_track_start "$@"` once near the top; an EXIT trap covers every exit.
z_track_start() {
[ "${Z_TRACK_ACTIVE:-0}" = "1" ] && return # nested under another z-script
command -v mktemp >/dev/null 2>&1 || return
export Z_TRACK_ACTIVE=1
Z_TRACK_SCRIPT="$(basename "$0")"
Z_TRACK_ARGS="$*"
Z_TRACK_FILE="$(mktemp "${TMPDIR:-/tmp}/ztrack.XXXXXX")" || { Z_TRACK_FILE=""; return; }
Z_TRACK_FIFO="${Z_TRACK_FILE}.fifo"
if ! mkfifo "$Z_TRACK_FIFO" 2>/dev/null; then rm -f "$Z_TRACK_FILE"; Z_TRACK_FILE=""; return; fi
# tee copies the FIFO to the log file while still showing output on the terminal.
tee "$Z_TRACK_FILE" < "$Z_TRACK_FIFO" &
Z_TRACK_TEE=$!
exec 3>&1 4>&2 >"$Z_TRACK_FIFO" 2>&1
trap z_track_stop EXIT
}
z_track_stop() {
set +e # never let cleanup abort mid-way
[ -n "${Z_TRACK_FILE:-}" ] || return
exec 1>&3 2>&4 3>&- 4>&- # restore stdout/stderr, close the pipe
[ -n "${Z_TRACK_TEE:-}" ] && wait "$Z_TRACK_TEE" 2>/dev/null
rm -f "${Z_TRACK_FIFO:-}" 2>/dev/null
local esc plain lines chars tok
esc=$'\033'
plain="${Z_TRACK_FILE}.plain"
sed "s/${esc}\[[0-9;]*m//g" "$Z_TRACK_FILE" > "$plain" 2>/dev/null || cp "$Z_TRACK_FILE" "$plain" 2>/dev/null
lines="$(grep -cve '^[[:space:]]*$' "$plain" 2>/dev/null)"; [ -n "$lines" ] || lines=0
chars="$(wc -m < "$plain" 2>/dev/null | tr -d ' ')"; [ -n "$chars" ] || chars=0
tok="$(awk -v c="$chars" 'BEGIN{printf "%d", int(c/3.5 + 0.5)}')"
printf '\n%s--- %s lines / %s chars / ~%s tokens est. (Claude Code) ---%s\n' \
"${C_GRAY:-}" "$(_z_commafy "$lines")" "$(_z_commafy "$chars")" "$(_z_commafy "$tok")" "${C_RESET:-}"
z_record "$lines" "$chars" "$tok"
rm -f "$Z_TRACK_FILE" "$plain" 2>/dev/null
Z_TRACK_FILE=""
}
# Portable thousands separators for a non-negative integer string (avoids both
# locale-dependent printf %'d and the GNU-only `sed :a;...;ta` idiom, which
# errors on BSD/macOS sed).
_z_commafy() {
awk -v n="$1" 'BEGIN{
s = n ""; out = ""
while (length(s) > 3) { out = "," substr(s, length(s) - 2) out; s = substr(s, 1, length(s) - 3) }
printf "%s%s", s, out
}'
}
# Append a run to the ztokens JSONL store. Data dir precedence: $ZTOKENS_DATA,
# then config "ztokens.dataDir", then the sibling ../../ztokens/data if present
# (dev layout), else ~/.ztokens/data. No-op without jq. Model tag is
# $ZTOKENS_MODEL or "est. chars/3.5".
z_record() { # <lines> <chars> <est>
command -v jq >/dev/null 2>&1 || return
local lines="$1" chars="$2" est="$3" dir model projects
dir="${ZTOKENS_DATA:-}"
[ -z "$dir" ] && [ -f "$ZCONFIG" ] && dir="$(z_path "$(jq -r '.ztokens.dataDir // empty' "$ZCONFIG" 2>/dev/null)")"
if [ -z "$dir" ]; then
if [ -d "$_ZDIR/../../ztokens/data" ]; then dir="$_ZDIR/../../ztokens/data"; else dir="$HOME/.ztokens/data"; fi
fi
mkdir -p "$dir" 2>/dev/null || return
model="${ZTOKENS_MODEL:-est. chars/3.5}"
projects=""
[ -n "${Z_TRACK_ARGS:-}" ] && projects="$(printf '%s\n' $Z_TRACK_ARGS | grep -v '^-' | paste -sd',' - 2>/dev/null)"
jq -cn \
--arg ts "$(date +%Y-%m-%dT%H:%M:%S%z)" \
--arg script "${Z_TRACK_SCRIPT%.*}" \
--arg projects "$projects" \
--argjson lines "${lines:-0}" --argjson chars "${chars:-0}" --argjson est "${est:-0}" \
--arg model "$model" \
'{ts:$ts,script:$script,projects:$projects,lines:$lines,chars:$chars,est:$est,model:$model}' \
>> "$dir/tokens.jsonl" 2>/dev/null
}

View File

@ -1,77 +0,0 @@
#!/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_track_start "$@"
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> ...] | all [--port N] [--kill-all]"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " 'all' stops the dev server of every project that has one."
exit 1
fi
# 'all' -> every project that actually has a local dev server (a ports.dev).
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
dev_keys=(); while IFS= read -r _zl || [ -n "$_zl" ]; do dev_keys+=("$_zl"); done \
< <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.ports.dev != null) | .key' "$ZCONFIG")
if [ "${#dev_keys[@]}" -eq 0 ]; then
warn "No projects have a dev port to kill."
exit 0
fi
projects=("${dev_keys[@]}")
info "Killing dev servers for: $(printf '%s ' "${projects[@]}")"
fi
# --kill-all (kill stray project processes beyond the dev-port listener) isn't
# ported yet; announce it rather than silently accepting the flag and doing nothing.
[ "$kill_all" -eq 1 ] && warn "Note: --kill-all isn't implemented in the bash port yet - killing dev-port listeners only."
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."

View File

@ -1,80 +0,0 @@
#!/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_track_start "$@"
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 $domain (via http://$HOST)..."
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'

View File

@ -1,54 +0,0 @@
#!/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_track_start "$@"
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

View File

@ -1,95 +0,0 @@
#!/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 — prepare a project for local dev: create its Python venv and install
# dependencies (python kind), or run `npm install` (vite/nextjs). Idempotent -
# safe to re-run to refresh deps. `zstart` only warns when a venv is missing;
# this is the command that creates it.
#
# Usage:
# zsetup <project> [<project> ...]
# zsetup <project> --force # recreate the python venv from scratch
#
# Python install command (first match wins):
# 1. the project's "install" config field (e.g. "-e backend", "-r reqs.txt")
# 2. auto: pyproject.toml / setup.py -> "-e ." | requirements.txt -> "-r requirements.txt"
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_track_start "$@"
z_need_config
projects=(); force=0
while [ $# -gt 0 ]; do
case "$1" in
--force) force=1; shift ;;
-*) err "Unknown option: $1"; exit 1 ;;
*) projects+=("$1"); shift ;;
esac
done
if [ "${#projects[@]}" -eq 0 ]; then
printf '\n'; warn "Usage: zsetup <project> [<project> ...] [--force]"
dim " Projects in zconfig.json: $(zproj_csv)"
dim " Creates the python venv + installs deps (or runs npm install for vite/nextjs)."
exit 1
fi
# pip install args for a python project (one per line): config .install, else
# auto-detect a root pyproject.toml/setup.py or requirements.txt. Empty = unknown.
py_install_args() { # <key> <root>
local key="$1" root="$2" cfg
cfg="$(zproj "$key" .install)"
if [ -n "$cfg" ]; then printf '%s\n' $cfg; return 0; fi
if [ -f "$root/pyproject.toml" ] || [ -f "$root/setup.py" ]; then printf '%s\n' -e .; return 0; fi
if [ -f "$root/requirements.txt" ]; then printf '%s\n' -r requirements.txt; return 0; fi
return 0
}
setup_python() { # <key> <root>
local key="$1" root="$2" venv="$2/.venv" args=() a
z_require python3
while IFS= read -r a; do [ -n "$a" ] && args+=("$a"); done < <(py_install_args "$key" "$root")
if [ "${#args[@]}" -eq 0 ]; then
err " Can't determine dependencies for '$key' (no pyproject.toml/setup.py/requirements.txt at $root)."
dim " Add an \"install\" field to the project, e.g. \"install\": \"-e backend\" or \"-r requirements.txt\"."
return 1
fi
if [ "$force" -eq 1 ] && [ -d "$venv" ]; then dim " Removing existing venv (--force)..."; rm -rf "$venv"; fi
if [ ! -x "$venv/bin/python" ]; then
info " Creating venv: $venv"
python3 -m venv "$venv" || { err " venv creation failed"; return 1; }
else
dim " venv exists: $venv (installing/updating deps)"
fi
info " pip install ${args[*]}"
( cd "$root" && "$venv/bin/pip" install "${args[@]}" ) || { err " pip install failed"; return 1; }
ok " $key ready - zstart $key will use $venv/bin/python."
}
setup_node() { # <key> <root>
local key="$1" root="$2"
[ -f "$root/package.json" ] || { err " No package.json in $root"; return 1; }
z_require npm
info " npm install (in $root)..."
( cd "$root" && npm install ) || { err " npm install failed"; return 1; }
ok " $key node_modules ready."
}
rc=0
for key in "${projects[@]}"; do
zproj_require "$key"
kind="$(zproj "$key" .kind)"
root="$(zproj_root "$key")"
printf '\n'; info "=== zsetup ($(zproj "$key" .label)) ==="
[ -d "$root" ] || { err " Project root not found: $root"; rc=1; continue; }
case "$kind" in
python) setup_python "$key" "$root" || rc=1 ;;
vite|nextjs) setup_node "$key" "$root" || rc=1 ;;
*) dim " kind '$kind' - nothing to set up locally." ;;
esac
done
printf '\n'
exit "$rc"

View File

@ -1,107 +0,0 @@
#!/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_track_start "$@"
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!"

View File

@ -1,203 +0,0 @@
#!/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>, or uvicorn <startApp> for
# ASGI/FastAPI apps; prefers .venv), vite (npm run dev -- --host --port),
# nextjs (npm run dev with PORT).
# Detached servers log to /tmp/zstart-<project>.log ; stop them with zkill.
set -uo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
z_track_start "$@"
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"
# Sever every fd tied to this shell before exec'ing the long-lived server:
# stdin<-/dev/null, stdout/stderr->log, and CLOSE the ztokens tracking fds
# (3/4 = saved stdout/stderr; 1/2 fed the capture FIFO). If the server keeps
# any open, the parent's EXIT-trap footer (a `tee` on the FIFO) never gets
# EOF and `zstart --detached` hangs instead of returning.
( cd "$dir" && exec </dev/null >"$log" 2>&1 3>&- 4>&-; exec nohup "$@" ) &
disown 2>/dev/null || true
ok "Started in detached mode (logs: $log)."
dim "Use zkill $key to stop it."
}
# 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_root "$key")"
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
# GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast instead
# of blocking the server start on an interactive "Username for ..." prompt.
local out rc
out="$( (cd "$root" && GIT_TERMINAL_PROMPT=0 git pull --ff-only 2>&1) )"; rc=$?
dim " git pull: $(printf '%s\n' "$out" | tail -1)"
[ "$rc" -eq 0 ] || warn " Auto-pull skipped (exit $rc) - starting with the current checkout. (git needs credentials here, or set start.gitPull=false)"
fi
}
start_python() { # <key> <port>
local key="$1" port="$2" root module app exe bv="" run_cmd=() what using_venv=1
root="$(zproj_root "$key")"
module="$(zproj "$key" .startModule)"
app="$(zproj "$key" .startApp)"
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
[ -n "$module" ] || [ -n "$app" ] || { err "Project '$key' (kind=python) needs 'startModule' (python -m ...) or 'startApp' (uvicorn app:app) in zconfig.json."; return 1; }
# Prefer the project venv. The Scripts/python.exe branch is a *Windows* venv
# layout — only runnable from a Windows-family shell (Git Bash/MSYS/Cygwin).
# On WSL/Linux/macOS a Windows-built venv may sit on a mounted drive and look
# executable but can't exec, so guard that branch to fall through to python3.
if [ -x "$root/.venv/bin/python" ]; then exe="$root/.venv/bin/python"
elif [ -x "$root/.venv/Scripts/python.exe" ] && [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then exe="$root/.venv/Scripts/python.exe"
elif command -v python3 >/dev/null 2>&1; then exe="python3"; using_venv=0
else exe="python"; using_venv=0; fi
# Optional convention: scripts/build_version_tool.py bumps the version on dev start.
if [ -f "$root/scripts/build_version_tool.py" ]; then
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
# startApp (uvicorn ASGI target, e.g. app.main:app) takes precedence over
# startModule (python -m ...). uvicorn is run via the venv python's -m so no
# PATH juggling is needed, and it gets zstart's bind-host + dev port.
if [ -n "$app" ]; then
run_cmd=("$exe" -m uvicorn "$app" --host "$bind_host" --port "$port" --reload)
what="uvicorn $app"
else
run_cmd=("$exe" -m "$module")
what="python -m $module"
fi
printf '\n'; info "=== zstart ($(zproj "$key" .label)) ==="
# No project venv found - warn, since the system interpreter usually lacks the
# app's deps (the failure would otherwise be a cryptic ModuleNotFoundError).
if [ "$using_venv" -eq 0 ]; then
warn "No project venv at $root/.venv - using system '$exe' (its deps may be missing)."
dim " Create one: (cd \"$root\" && python3 -m venv .venv && .venv/bin/pip install -e .)"
fi
info "Starting $what on port $port..."
[ -n "$bv" ] && note "Build Version: $bv"
show_project_motd "$root"
dim "Press Ctrl+C to stop the server"
warn_if_privileged_port "$port"
printf '\n'
if [ "$detached" -eq 1 ]; then
detach "$key" "$root" "${run_cmd[@]}"
else
( cd "$root" && exec "${run_cmd[@]}" )
fi
}
start_vite() { # <key> <port>
local key="$1" port="$2" root
root="$(zproj_root "$key")"
[ -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_root "$key")"
[ -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"

View File

@ -1,66 +0,0 @@
#!/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_track_start "$@"
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

View File

@ -1,38 +0,0 @@
#!/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_track_start "$@"
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."

View File

@ -1,97 +0,0 @@
#!/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_track_start "$@"
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 "$(z_path "$(zq '.paths.backupsLocal')")")"
OFFSITE_ROOT="$(z_path "$(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_root "$key")"
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

View File

@ -1,232 +0,0 @@
#!/usr/bin/env bats
# 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.
#
# args.bats - how the bash scripts read their target arguments.
#
# bats tests/bash
#
# Mirrors tests/ArgumentParsing.Tests.ps1. Same safety rule as the PowerShell
# side: these run the real scripts, so they stay on code paths that exit BEFORE
# doing any work (no args, unknown key). Only zkill runs with a valid target,
# and only because the fixture's dev ports are deliberately unused and its
# localRoots do not exist - it finds no listeners and kills nothing. zdeploy,
# zbackup_ec2, zec2, zec2online, zrepair, zstop, zstart and zbackup are never
# invoked with a real target; that needs a disposable server.
setup() {
REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
BASHDIR="$REPO/bash"
export ZCONFIG="$BATS_TEST_TMPDIR/zconfig.json"
cat > "$ZCONFIG" <<'JSON'
{
"ec2": { "ip": "203.0.113.10", "user": "testuser", "pemKey": "/fixtures/test.pem", "stackRoot": "/home/testuser/stack" },
"paths": { "temp": "/fixtures/temp", "backupsLocal": "/fixtures/backups", "backupsEc2": "/fixtures/backups-ec2", "scriptsRoot": "/fixtures/zscripts", "oneDriveBackups": "" },
"projects": {
"_note": "comment key - must never be treated as a project",
"pyapp": {
"label": "Fixture Python App", "kind": "python", "localRoot": "/fixtures/nonexistent-pyapp",
"startModule": "pyapp.main", "ports": { "dev": 59990 },
"remote": { "path": "/home/testuser/stack/pyapp" }
},
"viteapp": {
"label": "Fixture Vite Site", "kind": "vite", "localRoot": "/fixtures/nonexistent-viteapp",
"ports": { "dev": 59991 }, "remote": { "path": "/home/testuser/stack/viteapp" }
},
"edgeproxy": {
"label": "Fixture Edge", "kind": "edge", "localRoot": "/fixtures/nonexistent-edge",
"remote": { "path": "/home/testuser/stack/edge" }
}
}
}
JSON
}
# ---- no arguments -----------------------------------------------------------
# Running bare must never mean "do it to everything".
@test "zkill with no args shows usage and exits 1" {
run "$BASHDIR/zkill"
[ "$status" -eq 1 ]
[[ "$output" == *"Usage"* ]]
}
@test "zstart with no args shows usage and exits non-zero" {
run "$BASHDIR/zstart"
[ "$status" -ne 0 ]
[[ "$output" == *"Usage"* ]]
}
@test "zdeploy with no args shows usage and exits non-zero" {
run "$BASHDIR/zdeploy"
[ "$status" -ne 0 ]
[[ "$output" == *"Usage"* ]]
}
@test "zbackup with no args shows usage and exits non-zero" {
run "$BASHDIR/zbackup"
[ "$status" -ne 0 ]
[[ "$output" == *"Usage"* ]]
}
@test "usage lists the configured project keys" {
run "$BASHDIR/zkill"
[[ "$output" == *"pyapp"* ]]
[[ "$output" == *"viteapp"* ]]
}
@test "usage never advertises the underscore comment key" {
run "$BASHDIR/zkill"
[[ "$output" != *"_note"* ]]
}
# ---- unknown key ------------------------------------------------------------
# Exiting 0 having selected nothing is the dangerous outcome.
@test "zkill rejects an unknown project key" {
run "$BASHDIR/zkill" definitelynotaproject
[ "$status" -ne 0 ]
[[ "$output" == *"Unknown project key"* ]]
}
@test "zstart rejects an unknown project key" {
run "$BASHDIR/zstart" definitelynotaproject
[ "$status" -ne 0 ]
}
@test "zdeploy rejects an unknown project key" {
run "$BASHDIR/zdeploy" definitelynotaproject
[ "$status" -ne 0 ]
}
@test "the unknown-key error names both the bad key and the valid ones" {
run "$BASHDIR/zkill" definitelynotaproject
[[ "$output" == *"definitelynotaproject"* ]]
[[ "$output" == *"pyapp"* ]]
}
@test "the underscore comment key is not addressable as a project" {
run "$BASHDIR/zkill" _note
[ "$status" -ne 0 ]
}
# The convention is that ANY "_"-prefixed key is a comment, not a project -
# PowerShell's Get-ZProject refuses them outright. A string-valued comment is
# also caught by the "is it an object?" check, so this uses an OBJECT-valued
# underscore key (e.g. someone keeping a "_template" block to copy from) to
# prove the prefix rule itself is enforced.
@test "an object-valued underscore key is still not addressable" {
cat > "$BATS_TEST_TMPDIR/objkey.json" <<'JSON'
{
"ec2": { "ip": "203.0.113.10", "user": "testuser", "pemKey": "/f/t.pem", "stackRoot": "/s" },
"paths": { "temp": "/f/t", "backupsLocal": "/f/b", "backupsEc2": "/f/be", "scriptsRoot": "/f/z", "oneDriveBackups": "" },
"projects": {
"_template": {
"label": "Template To Copy", "kind": "python", "localRoot": "/fixtures/none",
"ports": { "dev": 59993 }, "remote": { "path": "/s/t" }
},
"pyapp": {
"label": "Fixture Python App", "kind": "python", "localRoot": "/fixtures/nonexistent-pyapp",
"ports": { "dev": 59990 }, "remote": { "path": "/s/py" }
}
}
}
JSON
ZCONFIG="$BATS_TEST_TMPDIR/objkey.json" run "$BASHDIR/zkill" _template
[ "$status" -ne 0 ]
[[ "$output" == *"Unknown project key"* ]]
}
@test "an object-valued underscore key is excluded from 'all'" {
cat > "$BATS_TEST_TMPDIR/objkey2.json" <<'JSON'
{
"ec2": { "ip": "203.0.113.10", "user": "testuser", "pemKey": "/f/t.pem", "stackRoot": "/s" },
"paths": { "temp": "/f/t", "backupsLocal": "/f/b", "backupsEc2": "/f/be", "scriptsRoot": "/f/z", "oneDriveBackups": "" },
"projects": {
"_template": {
"label": "Template To Copy", "kind": "python", "localRoot": "/fixtures/none",
"ports": { "dev": 59993 }, "remote": { "path": "/s/t" }
},
"pyapp": {
"label": "Fixture Python App", "kind": "python", "localRoot": "/fixtures/nonexistent-pyapp",
"ports": { "dev": 59990 }, "remote": { "path": "/s/py" }
}
}
}
JSON
ZCONFIG="$BATS_TEST_TMPDIR/objkey2.json" run "$BASHDIR/zkill" all
[ "$status" -eq 0 ]
[[ "$output" == *"Fixture Python App"* ]]
[[ "$output" != *"Template To Copy"* ]]
}
# ---- leading dash: CURRENT bash behaviour ------------------------------------
# NOTE: this is a known divergence from the PowerShell port, pinned here as it
# actually behaves rather than as it ideally would. PowerShell tolerates a
# leading dash on any project key (zdeploy -myapp == zdeploy myapp). In bash,
# only zdeploy strips it ("${1#-}"); every other script treats -myapp as an
# unknown option and exits 1. These tests document today's behaviour so a fix
# is a deliberate change with a failing test to update, not a silent surprise.
@test "bash zdeploy strips a leading dash from a project key" {
# Unknown key keeps this on the no-work path; the error must name the key
# without its dash, proving the strip happened.
run "$BASHDIR/zdeploy" -definitelynotaproject
[ "$status" -ne 0 ]
[[ "$output" == *"definitelynotaproject"* ]]
[[ "$output" != *"-definitelynotaproject"* ]]
}
@test "bash zkill rejects a dashed key as an unknown option (diverges from PowerShell)" {
run "$BASHDIR/zkill" -pyapp
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown option"* ]]
}
@test "bash zstart rejects a dashed key as an unknown option (diverges from PowerShell)" {
run "$BASHDIR/zstart" -pyapp
[ "$status" -ne 0 ]
[[ "$output" == *"Unknown option"* ]]
}
# ---- zkill target resolution (safe: fixture ports are unused) ----------------
@test "zkill accepts a bare project key" {
run "$BASHDIR/zkill" pyapp
[ "$status" -eq 0 ]
[[ "$output" == *"Fixture Python App"* ]]
[[ "$output" == *"59990"* ]]
}
@test "zkill accepts several keys at once" {
run "$BASHDIR/zkill" pyapp viteapp
[ "$status" -eq 0 ]
[[ "$output" == *"Fixture Python App"* ]]
[[ "$output" == *"Fixture Vite Site"* ]]
}
@test "zkill 'all' expands to every project with a dev port" {
run "$BASHDIR/zkill" all
[ "$status" -eq 0 ]
[[ "$output" == *"Fixture Python App"* ]]
[[ "$output" == *"Fixture Vite Site"* ]]
}
@test "zkill 'all' skips projects with no dev port (edge/docker stacks)" {
run "$BASHDIR/zkill" all
[[ "$output" != *"Fixture Edge"* ]]
}
@test "zkill --port overrides the configured dev port" {
run "$BASHDIR/zkill" pyapp --port 59999
[ "$status" -eq 0 ]
[[ "$output" == *"59999"* ]]
[[ "$output" != *"59990"* ]]
}
@test "zkill announces that --kill-all is not implemented rather than silently ignoring it" {
run "$BASHDIR/zkill" pyapp --kill-all
[ "$status" -eq 0 ]
[[ "$output" == *"kill-all"* ]]
}

View File

@ -1,237 +0,0 @@
#!/usr/bin/env bats
# 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.
#
# helpers.bats - coverage for the pure/config functions in bash/zhelpers.sh.
#
# bats tests/bash
#
# The bash port has its own implementation of the exclude lists and config
# accessors, so it can drift from the PowerShell side independently. These
# mirror tests/ZHelpers.Tests.ps1 assertion-for-assertion where the two are
# meant to agree.
#
# A fixture config is injected through ZCONFIG (zhelpers.sh has always honored
# it), so no real zconfig.json is read.
setup() {
REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
export ZCONFIG="$BATS_TEST_TMPDIR/zconfig.json"
cat > "$ZCONFIG" <<'JSON'
{
"ec2": { "ip": "203.0.113.10", "user": "testuser", "pemKey": "/fixtures/test.pem", "stackRoot": "/home/testuser/stack" },
"paths": { "temp": "/fixtures/temp", "backupsLocal": "/fixtures/backups", "backupsEc2": "/fixtures/backups-ec2", "scriptsRoot": "/fixtures/zscripts", "oneDriveBackups": "" },
"projects": {
"_note": "comment key - must never be treated as a project",
"pyapp": {
"label": "Fixture Python App", "kind": "python", "localRoot": "/fixtures/pyapp",
"startModule": "pyapp.main", "ports": { "dev": 59990 }, "domain": "pyapp.example.com",
"remote": { "path": "/home/testuser/stack/pyapp", "composeDir": "/home/testuser/stack/pyapp/docker", "appService": "app" },
"deploy": { "zipName": "PyAppDeploy.zip", "exclude": ["docs", "fixtures-extra"] }
},
"viteapp": {
"label": "Fixture Vite Site", "kind": "vite", "localRoot": "/fixtures/viteapp",
"ports": { "dev": 59991 }, "domain": "www.example.com",
"remote": { "path": "/home/testuser/stack/viteapp" }
},
"nextapp": {
"label": "Fixture Next App", "kind": "nextjs", "localRoot": "/fixtures/nextapp",
"ports": { "dev": 59992 }, "remote": { "path": "/home/testuser/stack/nextapp" }
},
"edgeproxy": {
"label": "Fixture Edge", "kind": "edge", "localRoot": "/fixtures/edge",
"proxyContainer": "edge_proxy", "remote": { "path": "/home/testuser/stack/edge" }
},
"noports": {
"label": "Fixture No Dev Port", "kind": "python", "localRoot": "/fixtures/noports",
"remote": { "path": "/home/testuser/stack/noports" }
}
}
}
JSON
# shellcheck disable=SC1090
source "$REPO/bash/zhelpers.sh"
}
# Emit the exclude list for a project; $2=1 means "for backup".
excludes() { z_archive_excludes "$1" "${2:-0}"; }
# Fixed-string, whole-line match. -F matters: without it grep treats the needle
# as a regex, and '.env' would match 'venv' (leading '.' = any char) - which
# silently turns every ".env is excluded" assertion into a false pass.
has() { printf '%s\n' "$1" | grep -qxF "$2"; }
# ---- config accessors -------------------------------------------------------
@test "zproj_keys returns every project in config order" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj_keys | paste -sd, -"
[ "$status" -eq 0 ]
[ "$output" = "pyapp,viteapp,nextapp,edgeproxy,noports" ]
}
@test "zproj_keys omits underscore comment keys" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj_keys"
[ "$status" -eq 0 ]
! has "$output" "_note"
}
@test "zproj reads a project field" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj pyapp .label"
[ "$output" = "Fixture Python App" ]
}
@test "zproj returns empty for a missing field rather than the string null" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj viteapp .startModule"
[ "$output" = "" ]
}
@test "zec2_target composes user@ip" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zec2_target"
[ "$output" = "testuser@203.0.113.10" ]
}
@test "zremote_compose_dir prefers composeDir" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zremote_compose_dir pyapp"
[ "$output" = "/home/testuser/stack/pyapp/docker" ]
}
@test "zremote_compose_dir falls back to remote.path" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zremote_compose_dir viteapp"
[ "$output" = "/home/testuser/stack/viteapp" ]
}
@test "zedge_key finds the first edge-kind project" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zedge_key"
[ "$output" = "edgeproxy" ]
}
@test "zproj_keys_with_domain lists only projects that have a domain" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj_keys_with_domain | paste -sd, -"
[ "$output" = "pyapp,viteapp" ]
}
# ---- z_path (Windows -> host path translation) ------------------------------
# A shared zconfig.json may carry Windows paths when the same file is used from
# a Windows checkout and from WSL.
@test "z_path translates a Windows drive path to /mnt form" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path 'F:\\evomedia.net\\app'"
[ "$output" = "/mnt/f/evomedia.net/app" ]
}
@test "z_path lowercases the drive letter" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path 'C:\\Users\\Someone'"
[ "$output" = "/mnt/c/Users/Someone" ]
}
@test "z_path leaves a unix path untouched (no-op on native Linux/macOS)" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path '/home/kelly/project'"
[ "$output" = "/home/kelly/project" ]
}
@test "z_path leaves an empty string empty" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path ''"
[ "$output" = "" ]
}
# ---- z_archive_excludes -----------------------------------------------------
# Parity target: tests/ZHelpers.Tests.ps1 "Get-ArchiveExcludes".
@test "excludes: common junk applies to every kind" {
run excludes pyapp
for n in .git .idea .vscode .claude tmp nul .DS_Store backups; do
has "$output" "$n" || { echo "missing common exclude: $n"; return 1; }
done
}
@test "excludes: python build/venv cruft" {
run excludes pyapp
for n in .venv venv __pycache__ .pytest_cache .nicegui archive dist build htmlcov; do
has "$output" "$n" || { echo "missing python exclude: $n"; return 1; }
done
}
@test "excludes: vite drops node_modules and dist" {
run excludes viteapp
has "$output" "node_modules"
has "$output" "dist"
}
@test "excludes: nextjs drops .next and .vercel" {
run excludes nextapp
has "$output" ".next"
has "$output" ".vercel"
has "$output" "next-env.d.ts"
}
# The regression this suite exists for: a dev .env shipped to production in a
# deploy zip. Deploys drop secrets and uploads; backups must keep them or the
# backup is not a restore point.
@test "excludes: python DEPLOY drops .env, .env.local, .env.production and uploads" {
run excludes pyapp 0
for n in .env .env.local .env.production uploads; do
has "$output" "$n" || { echo "deploy should exclude: $n"; return 1; }
done
}
@test "excludes: python BACKUP keeps .env, .env.local, .env.production and uploads" {
run excludes pyapp 1
for n in .env .env.local .env.production uploads; do
if has "$output" "$n"; then echo "backup must NOT exclude: $n"; return 1; fi
done
}
@test "excludes: python BACKUP still drops the virtualenv (bulk, not a secret)" {
run excludes pyapp 1
has "$output" ".venv"
}
@test "excludes: vite DEPLOY drops .env files" {
run excludes viteapp 0
for n in .env .env.local .env.production; do
has "$output" "$n" || { echo "deploy should exclude: $n"; return 1; }
done
}
@test "excludes: vite BACKUP keeps .env files" {
run excludes viteapp 1
for n in .env .env.local .env.production; do
if has "$output" "$n"; then echo "backup must NOT exclude: $n"; return 1; fi
done
}
@test "excludes: vite BACKUP still drops node_modules" {
run excludes viteapp 1
has "$output" "node_modules"
}
# nextjs has no backup gate on the PowerShell side either - both agree.
@test "excludes: nextjs drops .env in a backup too (no gate, matches PowerShell)" {
run excludes nextapp 1
has "$output" ".env"
}
@test "excludes: the project's own deploy.exclude entries are merged in" {
run excludes pyapp
has "$output" "docs"
has "$output" "fixtures-extra"
}
@test "excludes: a kind with no specific list still gets the common entries" {
run excludes edgeproxy
has "$output" ".git"
}
# ---- json_build_label -------------------------------------------------------
@test "json_build_label formats v<productVersion>.<buildNumber>" {
printf '{ "productVersion": "1.0", "buildNumber": 42 }' > "$BATS_TEST_TMPDIR/bv.json"
run bash -c "source '$REPO/bash/zhelpers.sh'; json_build_label '$BATS_TEST_TMPDIR/bv.json'"
[ "$output" = "v1.0.42" ]
}
@test "json_build_label reports unknown for a missing file" {
run bash -c "source '$REPO/bash/zhelpers.sh'; json_build_label '$BATS_TEST_TMPDIR/nope.json'"
[ "$output" = "unknown" ]
}