mirror of
https://github.com/kellymichels/zscripts-token-savers
synced 2026-10-08 07:28:18 +00:00
feat(bash): native bash port of all z-scripts for Linux/macOS/WSL (#5)
* feat(bash): native bash port of all z-scripts for Linux/macOS/WSL Full port: zhelpers.sh library (jq config, ssh, http/tcp, archive builder, build-version, motd, port-kill), all commands (zstart/zkill/zrestart/zstop, zdeploy with 5 kind handlers, zec2/zec2online/zrepair, zbackup/zbackup_ec2/ zsync/zbackup_and_sync, zstart_docker, zsetup_mail, setup_backup_schedule via cron), Unix-path zconfig.example.json, and a bash/README.md. Verified: bash -n clean on all scripts; archive exclusions, config semantics, and zec2 tested against the real config and live server from Git Bash. Needs a Linux/macOS/WSL shakedown for lsof/rsync/nohup paths before merging. * chore: pin line endings (.gitattributes) - bash LF, powershell CRLF * docs(readme): point Linux/macOS/WSL users at the bash port * fix(bash): don't run the Windows venv python.exe on WSL/Linux/macOS zstart's venv detection fell back to .venv/Scripts/python.exe (a Windows binary) whenever it existed. On a Windows-built project accessed from WSL that file sits on the mount and looks executable, so it got picked and failed with 'exec format error' instead of falling through to python3. Guard that branch to Windows-family shells (msys/cygwin), where a .exe can actually run. Verified on WSL: zstart --detached now backgrounds a stdlib app via python3, serves HTTP 200, logs to /tmp/zstart-<key>.log, and zkill terminates it and frees the port. * feat(bash): add token-usage tracking to the bash port (#6) Adds z_track_start/z_track_stop + z_record to zhelpers.sh and wires z_track_start into every command script. Each run now captures its own output volume (FIFO+tee, ANSI stripped), prints the '--- N lines / N chars / ~N tokens est. (Claude Code) ---' footer, and appends one JSONL row per top-level run in the same shape as the PowerShell tokens.jsonl. A nested-run guard keeps zrestart from double-counting its zkill/zstart children. Data dir precedence: $ZTOKENS_DATA, config ztokens.dataDir, sibling ../../ztokens/data, else ~/.ztokens/data. Docs + example config updated. Verified on WSL (isolated data dir): single run records correctly; nested zrestart produces one combined record, not three; est = round(chars/3.5).
This commit is contained in:
parent
d046768c67
commit
09f86c1cba
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
# Bash scripts must stay LF — CRLF breaks the shebang on Linux/macOS/WSL.
|
||||
bash/** text eol=lf
|
||||
|
||||
# PowerShell/cmd are happiest with CRLF on Windows.
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
17
README.md
17
README.md
@ -14,6 +14,8 @@ Every command is a tiny PowerShell script driven by a single JSON config file. T
|
||||
|
||||
**Requirements:** Windows, PowerShell 5.1+, OpenSSH client (`ssh`/`scp`, ships with Windows 10/11), and Docker + docker compose on the remote host for the deploy scripts.
|
||||
|
||||
**Linux / macOS / WSL:** a native bash port of every command lives in [`bash/`](bash/README.md) — same config schema, same commands, no PowerShell needed.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
@ -120,7 +122,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more
|
||||
| `zec2 [<key> ...]` | Quick reachability check (TCP + HTTP + live build version) |
|
||||
| `zec2online [<key> ...]` | Deep health check; auto-starts downed stacks, streams diagnostics |
|
||||
| `zrepair <key> ...` | Audit + repair compose/proxy state on the server |
|
||||
| `zbackup [<key> ...]` | Zip local project sources (+ DB dump) to the backups folder |
|
||||
| `zbackup <key> ... \| all` | Zip local project sources (+ DB dump) to the backups folder |
|
||||
| `zbackup_ec2 [<key> ...]` | Pull DB dumps + server-side data files down from the server |
|
||||
| `zsync [<key>]` | Copy new backups offsite (or build + mirror a vite dist) |
|
||||
| `zstart_docker` | Run a local docker compose stack from `scriptsRoot\docker\` |
|
||||
@ -224,14 +226,15 @@ zstop <project> [<project> ...]
|
||||
#### `zbackup` — local backups
|
||||
|
||||
```
|
||||
zbackup [<project> ...] [-Tag "label"] # no args = every project + this scripts folder
|
||||
zbackup <project> [<project> ...] [-Tag "label"]
|
||||
zbackup all # every project + this scripts folder
|
||||
zbackup scripts # just this scripts folder ('scripts' is reserved)
|
||||
```
|
||||
|
||||
Zips each project's source into `paths.backupsLocal\<key>\<timestamp>_<key>[_tag].zip`. If the project's `.env` declares a `DATABASE_URL`, a Postgres dump is bundled into the zip automatically (`backend\.env` is checked too, for frontend/backend split projects). `-Tag` labels the archive — handy before risky changes.
|
||||
Zips each project's source into `paths.backupsLocal\<key>\<timestamp>_<key>[_tag].zip`. If the project's `.env` declares a `DATABASE_URL`, a Postgres dump is bundled into the zip automatically — quoted values (Prisma-style), `postgres://`/`postgresql+driver://` schemes, and URLs without an explicit port all parse. `backend\.env` is checked too, for frontend/backend split projects. `-Tag` labels the archive — handy before risky changes.
|
||||
|
||||
```powershell
|
||||
zbackup # everything
|
||||
zbackup all # everything
|
||||
zbackup pyapp -Tag "pre-migration"
|
||||
```
|
||||
|
||||
@ -255,14 +258,14 @@ The no-args mode copies only files that don't already exist at the destination (
|
||||
#### `zbackup_and_sync.ps1` — both in one
|
||||
|
||||
```
|
||||
zbackup_and_sync.ps1 [<project> ...]
|
||||
zbackup_and_sync.ps1 <project> [<project> ...] | all
|
||||
```
|
||||
|
||||
Runs `zbackup`, then `zsync`. This is what the scheduled task calls.
|
||||
Runs `zbackup`, then `zsync`. This is what the scheduled task calls (with `all`).
|
||||
|
||||
#### `setup_backup_schedule.ps1` — nightly automation
|
||||
|
||||
Run **as Administrator** once. Creates a Windows Scheduled Task that runs `zbackup_and_sync.ps1` daily at 2:00 AM.
|
||||
Run **as Administrator** once. Creates a Windows Scheduled Task that runs `zbackup_and_sync.ps1 all` daily at 2:00 AM.
|
||||
|
||||
### Utilities
|
||||
|
||||
|
||||
86
bash/README.md
Normal file
86
bash/README.md
Normal file
@ -0,0 +1,86 @@
|
||||
<!--
|
||||
Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
Created by Kelly Michels · dev@evomedia.net
|
||||
Licensed under the MIT License. See LICENSE.
|
||||
-->
|
||||
|
||||
# Token Savers — bash port (Linux / macOS / WSL)
|
||||
|
||||
Native bash versions of the z-scripts. Same commands, same `zconfig.json` schema,
|
||||
same behavior — no PowerShell required.
|
||||
|
||||
## Requirements
|
||||
|
||||
- bash 4+, `jq`, `curl`, `zip`, OpenSSH (`ssh`/`scp`)
|
||||
- `lsof` (for `zkill`/`zrestart`), `rsync` (for `zsync <project>` mirror mode)
|
||||
- Docker + docker compose on the remote host for the deploy scripts
|
||||
|
||||
Debian/Ubuntu: `sudo apt install jq curl zip lsof rsync`
|
||||
macOS: `brew install jq` (the rest ships with the OS)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kellymichels/zscripts-token-savers.git ~/zscripts
|
||||
cd ~/zscripts/bash
|
||||
cp zconfig.example.json zconfig.json # then fill in your values
|
||||
chmod +x z* setup_backup_schedule
|
||||
echo 'export PATH="$HOME/zscripts/bash:$PATH"' >> ~/.bashrc && source ~/.bashrc
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Same set as the PowerShell versions — the project key in `zconfig.json` IS the
|
||||
command argument:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `zstart <p> [--detached]` | start local dev server (python/vite/nextjs) |
|
||||
| `zkill <p>` | free the project's dev port |
|
||||
| `zrestart <p> [--detached]` | kill + start in one command |
|
||||
| `zstop <p>` | stop the project's compose stack on the server |
|
||||
| `zdeploy <p> [--note "msg"]` | zip → scp → docker compose build/up → verify build version |
|
||||
| `zec2 [<p>]` | TCP + HTTP + live-version reachability check |
|
||||
| `zec2online [<p>]` | deep health check; auto-starts downed stacks |
|
||||
| `zrepair <p>` | audit/repair container + proxy routing, smoke test |
|
||||
| `zbackup [<p>] [--tag t]` | local source zip (+ pg_dump when .env has DATABASE_URL) |
|
||||
| `zbackup_ec2 [<p>]` | pull db dump + data dirs down from the server |
|
||||
| `zsync` | copy new backup files offsite (never overwrites) |
|
||||
| `zbackup_and_sync` | both of the above; cron-friendly |
|
||||
| `setup_backup_schedule --install` | daily backup cron job |
|
||||
| `zsetup_mail --domain d` | provision docker-mailserver mailboxes + print DNS records |
|
||||
| `zstart_docker` | bring up the local compose stack in `bash/docker/` |
|
||||
|
||||
Flags use GNU style (`--port 3000`, `--detached`) instead of PowerShell style
|
||||
(`-Port`, `-Detached`). Detached dev servers log to `/tmp/zstart-<project>.log`.
|
||||
|
||||
## Usage tracking
|
||||
|
||||
Every run prints a footer with its own output volume:
|
||||
|
||||
```
|
||||
--- 1,048 lines / 69,009 chars / ~19,717 tokens est. (Claude Code) ---
|
||||
```
|
||||
|
||||
and (when `jq` is available) appends one JSON line per run to `tokens.jsonl`,
|
||||
so you can see how much infrastructure output you keep out of an AI agent's
|
||||
context over time. Fields: `ts, script, projects, lines, chars, est, model`
|
||||
(`est` ≈ `chars / 3.5`). Nested runs (the `zkill`/`zstart` inside `zrestart`)
|
||||
are counted once, not double.
|
||||
|
||||
Where it's written, first match wins:
|
||||
|
||||
1. `$ZTOKENS_DATA`
|
||||
2. `ztokens.dataDir` in `zconfig.json`
|
||||
3. the sibling `../ztokens/data` directory, if present
|
||||
4. `~/.ztokens/data` (created on first run)
|
||||
|
||||
Set `ZTOKENS_MODEL` to tag records with a specific model; it defaults to
|
||||
`est. chars/3.5`.
|
||||
|
||||
## Differences from the PowerShell versions
|
||||
|
||||
- `zsync <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.
|
||||
51
bash/setup_backup_schedule
Normal file
51
bash/setup_backup_schedule
Normal file
@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# setup_backup_schedule — schedule daily zbackup_and_sync via cron.
|
||||
# (The PowerShell version creates a Windows Scheduled Task; this is the cron equivalent.)
|
||||
#
|
||||
# Usage:
|
||||
# setup_backup_schedule # print the crontab line to add
|
||||
# setup_backup_schedule --install # append it to your crontab (2:00 AM daily)
|
||||
# setup_backup_schedule --time 03:30 --install
|
||||
|
||||
set -uo pipefail
|
||||
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$_HERE/zhelpers.sh"
|
||||
z_track_start "$@"
|
||||
|
||||
install=0; at_time="02:00"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install) install=1; shift ;;
|
||||
--time) at_time="${2:-02:00}"; shift 2 ;;
|
||||
*) err "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
hh="${at_time%%:*}"; mm="${at_time##*:}"
|
||||
if ! [[ "$hh" =~ ^[0-9]{1,2}$ && "$mm" =~ ^[0-9]{2}$ ]] || [ "$hh" -gt 23 ] || [ "$mm" -gt 59 ]; then
|
||||
err "Invalid --time '$at_time' (expected HH:MM, 24h)"; exit 1
|
||||
fi
|
||||
|
||||
cron_line="${mm#0} ${hh#0} * * * $_HERE/zbackup_and_sync >> \$HOME/zbackup_and_sync.log 2>&1"
|
||||
|
||||
info "=== Backup schedule (cron) ==="
|
||||
dim "Daily at $at_time:"
|
||||
printf '\n %s\n\n' "$cron_line"
|
||||
|
||||
if [ "$install" -eq 1 ]; then
|
||||
z_require crontab
|
||||
if crontab -l 2>/dev/null | grep -qF "$_HERE/zbackup_and_sync"; then
|
||||
warn "A crontab entry for zbackup_and_sync already exists - not adding a duplicate."
|
||||
dim "Edit it with: crontab -e"
|
||||
exit 0
|
||||
fi
|
||||
( crontab -l 2>/dev/null; printf '%s\n' "$cron_line" ) | crontab - \
|
||||
|| { err "Failed to update crontab."; exit 1; }
|
||||
ok "Installed. Verify with: crontab -l"
|
||||
else
|
||||
dim "Run with --install to add it to your crontab, or add it manually with: crontab -e"
|
||||
fi
|
||||
132
bash/zbackup
Normal file
132
bash/zbackup
Normal file
@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zbackup — local backups: zip project sources (plus a Postgres dump when the
|
||||
# project's .env has a DATABASE_URL) into the backups folder.
|
||||
#
|
||||
# Usage:
|
||||
# zbackup # every project in zconfig.json + this scripts folder
|
||||
# zbackup <project> [<project> ...]
|
||||
# zbackup scripts # just this scripts folder ('scripts' is a reserved word)
|
||||
# zbackup pyapp --tag "pre-migration"
|
||||
#
|
||||
# Output: <paths.backupsLocal>/<project>/<timestamp>_<project>[_tag].zip
|
||||
|
||||
set -uo pipefail
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
|
||||
z_track_start "$@"
|
||||
z_need_config
|
||||
z_require zip
|
||||
|
||||
projects=(); tag=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--tag) tag="${2:-}"; shift 2 ;;
|
||||
-*) err "Unknown option: $1"; exit 1 ;;
|
||||
*) projects+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
BACKUP_ROOT="$(zq '.paths.backupsLocal')"
|
||||
include_scripts=0
|
||||
if [ "${#projects[@]}" -eq 0 ]; then
|
||||
mapfile -t projects < <(zproj_keys)
|
||||
include_scripts=1
|
||||
else
|
||||
filtered=()
|
||||
for p in "${projects[@]}"; do
|
||||
if [ "$p" = "scripts" ]; then include_scripts=1; else filtered+=("$p"); fi
|
||||
done
|
||||
projects=("${filtered[@]+"${filtered[@]}"}")
|
||||
fi
|
||||
|
||||
ts() { date +%Y%m%d-%H%M%S; }
|
||||
tag_suffix() { [ -n "$tag" ] && printf '_%s' "$(printf '%s' "$tag" | tr -s '[:space:]' '_')"; }
|
||||
|
||||
# Dump the project's Postgres database if its .env declares a DATABASE_URL.
|
||||
# Checks <root>/.env, then <root>/backend/.env. Returns 0 and writes $2 on success.
|
||||
local_pg_dump() { # <root> <out_path>
|
||||
local root="$1" out="$2" env_file db_url
|
||||
env_file="$root/.env"; [ -f "$env_file" ] || env_file="$root/backend/.env"
|
||||
[ -f "$env_file" ] || return 1
|
||||
db_url="$(grep -m1 '^DATABASE_URL=' "$env_file" | cut -d= -f2- | tr -d '[:space:]')"
|
||||
[ -n "$db_url" ] || return 1
|
||||
db_url="$(printf '%s' "$db_url" | sed -E 's|^postgresql\+[^:]+://|postgresql://|')"
|
||||
if [[ "$db_url" =~ ^postgresql://([^:]+):([^@]+)@([^:]+):([0-9]+)/([^?]+) ]]; then
|
||||
local user="${BASH_REMATCH[1]}" pass="${BASH_REMATCH[2]}" host="${BASH_REMATCH[3]}"
|
||||
local port="${BASH_REMATCH[4]}" db="${BASH_REMATCH[5]}"
|
||||
command -v pg_dump >/dev/null 2>&1 || { err ' pg_dump not installed - skipping PG backup'; return 1; }
|
||||
if PGPASSWORD="$(z_urldecode "$pass")" pg_dump -h "$host" -p "$port" -U "$user" -d "$db" -F p -f "$out" 2>/dev/null \
|
||||
&& [ -f "$out" ]; then
|
||||
ok " PostgreSQL dump: $(awk -v b="$(wc -c < "$out")" 'BEGIN{printf "%.1f", b/1024}') KB"
|
||||
return 0
|
||||
fi
|
||||
err " pg_dump failed"
|
||||
return 1
|
||||
fi
|
||||
err ' Could not parse DATABASE_URL - skipping PG backup'
|
||||
return 1
|
||||
}
|
||||
|
||||
declare -a summary
|
||||
exit_code=0
|
||||
|
||||
backup_project() { # <key>
|
||||
local key="$1" root out_dir zip_path dump_dir db_dump extra="-"
|
||||
zproj_require "$key"
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
out_dir="$BACKUP_ROOT/$key"; mkdir -p "$out_dir"
|
||||
zip_path="$out_dir/$(ts)_${key}$(tag_suffix).zip"
|
||||
|
||||
printf '\n'; info "=== [$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')] Local backup ($(zproj "$key" .label)) ==="
|
||||
dim " Output: $zip_path"
|
||||
[ -d "$root" ] || { err " Root not found: $root"; return 1; }
|
||||
|
||||
dump_dir="$(mktemp -d "${TMPDIR:-/tmp}/zbackup_${key}_XXXXXX")"
|
||||
db_dump="$dump_dir/database_pg.sql"
|
||||
warn " [1/3] Checking for a local database to dump..."
|
||||
if local_pg_dump "$root" "$db_dump"; then extra="$db_dump"
|
||||
else dim " No local DATABASE_URL - source-only backup."; fi
|
||||
|
||||
warn " [2/3] Archiving source..."
|
||||
local excl=()
|
||||
mapfile -t excl < <(z_archive_excludes "$key" 1)
|
||||
if ! z_archive "$root" "$zip_path" 0 "$extra" "${excl[@]}"; then
|
||||
rm -rf "$dump_dir"; return 1
|
||||
fi
|
||||
rm -rf "$dump_dir"
|
||||
warn " [3/3] Done."
|
||||
summary+=("$key -> $zip_path ($(z_size_mb "$zip_path") MB)")
|
||||
}
|
||||
|
||||
backup_scripts() {
|
||||
local out_dir zip_path scripts_root
|
||||
scripts_root="$(zq '.paths.scriptsRoot')"
|
||||
out_dir="$BACKUP_ROOT/scripts"; mkdir -p "$out_dir"
|
||||
zip_path="$out_dir/$(ts)_scripts$(tag_suffix).zip"
|
||||
|
||||
printf '\n'; info "=== [SCRIPTS] Local backup (this scripts folder) ==="
|
||||
dim " Output: $zip_path"
|
||||
[ -d "$scripts_root" ] || { err " Scripts root not found: $scripts_root"; return 1; }
|
||||
|
||||
warn " [1/2] Archiving source..."
|
||||
z_archive "$scripts_root" "$zip_path" 1 "-" .git archive tmp nul || return 1
|
||||
warn " [2/2] Done."
|
||||
summary+=("scripts -> $zip_path ($(z_size_mb "$zip_path") MB)")
|
||||
}
|
||||
|
||||
for key in "${projects[@]+"${projects[@]}"}"; do
|
||||
backup_project "$key" || { summary+=("$key -> FAILED"); exit_code=1; }
|
||||
done
|
||||
if [ "$include_scripts" -eq 1 ]; then
|
||||
backup_scripts || { summary+=("scripts -> FAILED"); exit_code=1; }
|
||||
fi
|
||||
|
||||
printf '\n'; info "=== Backup summary ==="
|
||||
for line in "${summary[@]+"${summary[@]}"}"; do
|
||||
case "$line" in *FAILED*) err " $line" ;; *) ok " $line" ;; esac
|
||||
done
|
||||
printf '\n'
|
||||
exit "$exit_code"
|
||||
48
bash/zbackup_and_sync
Normal file
48
bash/zbackup_and_sync
Normal file
@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zbackup_and_sync — run backups, then sync the backups folder offsite.
|
||||
#
|
||||
# Usage:
|
||||
# zbackup_and_sync # backup everything + sync
|
||||
# zbackup_and_sync <project> [<project> ...]
|
||||
#
|
||||
# Cron example (see setup_backup_schedule):
|
||||
# 0 2 * * * /path/to/zscripts/bash/zbackup_and_sync >> ~/zbackup.log 2>&1
|
||||
|
||||
set -uo pipefail
|
||||
_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$_HERE/zhelpers.sh"
|
||||
z_track_start "$@"
|
||||
z_need_config
|
||||
|
||||
printf '\n'
|
||||
info "============================================"
|
||||
info " Backup & Sync - $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
info "============================================"
|
||||
printf '\n'
|
||||
|
||||
warn "[1/2] Running backups..."
|
||||
"$_HERE/zbackup" "$@"
|
||||
backup_rc=$?
|
||||
if [ $backup_rc -ne 0 ]; then
|
||||
err "Backup failed (exit code: $backup_rc)"
|
||||
exit $backup_rc
|
||||
fi
|
||||
|
||||
printf '\n'; warn "[2/2] Syncing offsite..."
|
||||
"$_HERE/zsync"
|
||||
sync_rc=$?
|
||||
|
||||
printf '\n'
|
||||
info "============================================"
|
||||
if [ $sync_rc -eq 0 ]; then
|
||||
ok " Backup & Sync Complete [OK]"
|
||||
else
|
||||
warn " Sync had issues (exit code: $sync_rc)"
|
||||
fi
|
||||
info "============================================"
|
||||
printf '\n'
|
||||
exit $sync_rc
|
||||
100
bash/zbackup_ec2
Normal file
100
bash/zbackup_ec2
Normal file
@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zbackup_ec2 — pull backups down from the server: a Postgres dump for projects
|
||||
# with a "db" config block, plus a zip of server-side data dirs (uploads/archive/dist).
|
||||
#
|
||||
# Usage:
|
||||
# zbackup_ec2 # every project with a remote.path
|
||||
# zbackup_ec2 <project> [<project> ...]
|
||||
#
|
||||
# Output:
|
||||
# <paths.backupsEc2>/<project>/<timestamp>_<project>_db.sql (projects with a db block)
|
||||
# <paths.backupsEc2>/<project>/<timestamp>_<project>_files.zip
|
||||
|
||||
set -uo pipefail
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
|
||||
z_track_start "$@"
|
||||
z_need_config
|
||||
z_require ssh scp
|
||||
|
||||
projects=("$@")
|
||||
if [ "${#projects[@]}" -eq 0 ]; then
|
||||
mapfile -t projects < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.remote.path != null) | .key' "$ZCONFIG")
|
||||
fi
|
||||
|
||||
EC2_BACKUP_ROOT="$(zq '.paths.backupsEc2')"
|
||||
REMOTE_HOME="/home/$(zec2_user)"
|
||||
|
||||
scp_down() { # <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"
|
||||
85
bash/zconfig.example.json
Normal file
85
bash/zconfig.example.json
Normal file
@ -0,0 +1,85 @@
|
||||
{
|
||||
"_comment": "Copy this file to zconfig.json and fill in your values. zconfig.json is gitignored — never commit it.",
|
||||
|
||||
"ec2": {
|
||||
"ip": "YOUR_SERVER_IP",
|
||||
"user": "YOUR_SSH_USER",
|
||||
"pemKey": "/home/youruser/.ssh/YourKey.pem",
|
||||
"stackRoot": "/home/YOUR_SSH_USER/stack"
|
||||
},
|
||||
|
||||
"paths": {
|
||||
"temp": "/tmp/zscripts",
|
||||
"backupsLocal": "/home/youruser/backups/projects",
|
||||
"backupsEc2": "/home/youruser/backups/ec2",
|
||||
"scriptsRoot": "/home/youruser/zscripts/bash",
|
||||
"oneDriveBackups": ""
|
||||
},
|
||||
|
||||
"ztokens": {
|
||||
"_comment": "Optional. Where each run's token-usage stats (tokens.jsonl) are recorded. Omit to use ~/.ztokens/data.",
|
||||
"dataDir": "/home/youruser/.ztokens/data"
|
||||
},
|
||||
|
||||
"projects": {
|
||||
"_comment": "Rename these keys to your own project names — the key IS the command argument: zstart pyapp, zdeploy viteapp, zbackup nextapp. Add as many projects as you like. Keys starting with _ are ignored.",
|
||||
|
||||
"pyapp": {
|
||||
"label": "My Python App",
|
||||
"kind": "python",
|
||||
"localRoot": "/home/youruser/code/pyapp",
|
||||
"startModule": "pyapp.main",
|
||||
"ports": { "dev": 8080 },
|
||||
"domain": "pyapp.yourdomain.com",
|
||||
"start": {
|
||||
"_comment": "Optional zstart pre-steps: gitPull runs 'git pull --ff-only' first; env sets variables for the server process.",
|
||||
"gitPull": true,
|
||||
"env": { "MYAPP_DEBUG": "1" }
|
||||
},
|
||||
"db": { "user": "pyapp_user", "name": "pyapp_db" },
|
||||
"remote": {
|
||||
"path": "/home/YOUR_SSH_USER/stack/pyapp",
|
||||
"composeDir": "/home/YOUR_SSH_USER/stack/pyapp/docker",
|
||||
"appService": "app"
|
||||
},
|
||||
"deploy": { "zipName": "PyAppDeploy.zip", "gitPull": true, "exclude": ["docs"] }
|
||||
},
|
||||
|
||||
"viteapp": {
|
||||
"label": "My Vite Site",
|
||||
"kind": "vite",
|
||||
"localRoot": "/home/youruser/code/viteapp",
|
||||
"ports": { "dev": 5173 },
|
||||
"domain": "www.yourdomain.com",
|
||||
"remote": {
|
||||
"path": "/home/YOUR_SSH_USER/stack/viteapp",
|
||||
"containerName": "viteapp"
|
||||
},
|
||||
"deploy": { "zipName": "ViteAppDeploy.zip" }
|
||||
},
|
||||
|
||||
"nextapp": {
|
||||
"label": "My Next.js App",
|
||||
"kind": "nextjs",
|
||||
"localRoot": "/home/youruser/code/nextapp",
|
||||
"ports": { "dev": 4173, "prod": 3000 },
|
||||
"domain": "app.yourdomain.com",
|
||||
"migrations": "prisma",
|
||||
"db": { "user": "nextapp_user", "name": "nextapp_db" },
|
||||
"remote": {
|
||||
"path": "/home/YOUR_SSH_USER/stack/nextapp",
|
||||
"appService": "web"
|
||||
},
|
||||
"deploy": { "zipName": "NextAppDeploy.zip" }
|
||||
},
|
||||
|
||||
"edge": {
|
||||
"label": "Edge Proxy",
|
||||
"kind": "edge",
|
||||
"localRoot": "/home/youruser/code/edge",
|
||||
"proxyContainer": "edge-proxy",
|
||||
"certsSource": "/etc/letsencrypt",
|
||||
"remote": { "path": "/home/YOUR_SSH_USER/stack/edge" }
|
||||
}
|
||||
}
|
||||
}
|
||||
495
bash/zdeploy
Normal file
495
bash/zdeploy
Normal file
@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zdeploy — deploy any project defined in zconfig.json to the server.
|
||||
# Each project runs its own docker compose stack; the handler is picked by the
|
||||
# project's "kind": python | vite | nextjs | edge | docker.
|
||||
#
|
||||
# Usage:
|
||||
# zdeploy <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="$(zq '.paths.temp')"
|
||||
{ [ -z "$TEMP_ROOT" ] || [ "$TEMP_ROOT" = "null" ]; } && TEMP_ROOT="${TMPDIR:-/tmp}"
|
||||
mkdir -p "$TEMP_ROOT"
|
||||
|
||||
# 'all' -> every project, edge kinds first (the proxy must route before apps ship).
|
||||
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then
|
||||
mapfile -t edge_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG")
|
||||
mapfile -t rest_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind != "edge") | .key' "$ZCONFIG")
|
||||
projects=("${edge_keys[@]+"${edge_keys[@]}"}" "${rest_keys[@]+"${rest_keys[@]}"}")
|
||||
info "Deploying all projects: $(printf '%s ' "${projects[@]}")"
|
||||
fi
|
||||
|
||||
deploy_zip_name() { # <key>
|
||||
local zn; zn="$(zproj "$1" .deploy.zipName)"
|
||||
[ -n "$zn" ] && printf '%s' "$zn" || printf '%sDeploy.zip' "$1"
|
||||
}
|
||||
|
||||
# Pre-upload cleanup: remove stale deploy zips, prune docker, truncate big logs,
|
||||
# fail if under 1.5 GB free.
|
||||
preflight_cleanup() { # <extra_zip_to_remove...>
|
||||
printf '\n'; info "--- [Preflight] Freeing disk space on the server ---"
|
||||
local rm_clause="true"
|
||||
[ $# -gt 0 ] && rm_clause="rm -f $*"
|
||||
local cmd
|
||||
cmd="echo '--- df / before cleanup ---'; df -h /"
|
||||
cmd+="; echo '--- removing stale deploy artifacts ---'; $rm_clause"
|
||||
cmd+="; echo '--- pruning docker build cache + dangling images + stopped containers ---'"
|
||||
cmd+="; sudo docker container prune -f >/dev/null 2>&1 || true"
|
||||
cmd+="; sudo docker builder prune -f >/dev/null 2>&1 || true"
|
||||
cmd+="; sudo docker image prune -af >/dev/null 2>&1 || true"
|
||||
cmd+="; echo '--- truncating large container logs ---'"
|
||||
cmd+="; sudo find /var/lib/docker/containers/ -name '*-json.log' -size +50M -exec truncate -s 0 {} + 2>/dev/null || true"
|
||||
cmd+="; echo '--- df / after cleanup ---'; df -h /"
|
||||
cmd+='; avail_mb=$(df --output=avail -BM / | tail -n 1 | tr -dc 0-9)'
|
||||
cmd+='; [ -z "$avail_mb" ] && avail_mb=0'
|
||||
cmd+='; echo available_mb=$avail_mb'
|
||||
cmd+='; if [ "$avail_mb" -lt 1500 ]; then echo "ERROR: less than 1.5 GB free on /. Grow the root volume or run: sudo docker system prune -af" >&2; exit 11; fi'
|
||||
zssh "$cmd" || { err "Server pre-flight cleanup failed. Root volume too full (need ~1.5 GB free, ideally 3+)."; return 1; }
|
||||
}
|
||||
|
||||
# Post-deploy cleanup: prune what this deploy created; running stacks untouched.
|
||||
post_cleanup() { # <label>
|
||||
printf '\n'; info "--- [Post-deploy] Reclaiming disk space ($1) ---"
|
||||
local cmd
|
||||
cmd="sudo docker container prune -f >/dev/null 2>&1 || true"
|
||||
cmd+="; sudo docker builder prune -f >/dev/null 2>&1 || true"
|
||||
cmd+="; sudo docker image prune -af >/dev/null 2>&1 || true"
|
||||
cmd+="; sudo find /var/lib/docker/containers/ -name '*-json.log' -size +50M -exec truncate -s 0 {} + 2>/dev/null || true"
|
||||
cmd+="; df -h /"
|
||||
zssh "$cmd" || warn " Post-deploy cleanup returned non-zero; continuing."
|
||||
}
|
||||
|
||||
send_zip() { # <local_zip> <zip_name>
|
||||
scp -i "$(zec2_pem)" "$1" "$(zec2_target):$REMOTE_HOME/" \
|
||||
|| { err "SCP upload failed. Likely server disk space. Try: rm -f $REMOTE_HOME/$2"; return 1; }
|
||||
}
|
||||
|
||||
remote_unzip() { # <zip_name> <dest_path>
|
||||
zec2_step "unzip $1" \
|
||||
"test -f $REMOTE_HOME/$1 || { echo 'missing $REMOTE_HOME/$1'; exit 2; }; unzip -t $REMOTE_HOME/$1 || exit 3; unzip -o $REMOTE_HOME/$1 -d $2; uc=\$?; if [ \$uc -gt 1 ]; then exit \$uc; fi; exit 0"
|
||||
}
|
||||
|
||||
# ── Deploy verification (build-version match, not just HTTP 200 — a 200 can be
|
||||
# a stale cached build; the version match proves the new build is live) ─────
|
||||
|
||||
wait_verify_api() { # <key> <expected_label> <timeout_sec>
|
||||
local key="$1" expected="$2" timeout="${3:-60}" domain live deadline
|
||||
domain="$(zproj "$key" .domain)"
|
||||
printf '\n'; info "--- [$key] Live build verification (expect $expected) ---"
|
||||
deadline=$((SECONDS + timeout))
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
live="$(http_get "http://$EC2_IP/api/build-version" "$domain" | jq -r '.build_version // empty' 2>/dev/null)"
|
||||
if [ -n "$live" ]; then
|
||||
if [ "$live" = "$expected" ]; then
|
||||
ok " PASS - live build $live matches expected."
|
||||
return 0
|
||||
fi
|
||||
warn " Live build is $live, expected $expected - waiting..."
|
||||
else
|
||||
dim " /api/build-version not ready yet - waiting..."
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
warn " WARNING: live build did not match $expected within ${timeout}s (a stale build may be cached)."
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_verify_static() { # <key> <pre_product_version> <pre_build_number>
|
||||
local key="$1" pv="$2" bn="$3" expected container domain live deadline raw
|
||||
if [ -z "$pv" ] || [ -z "$bn" ]; then
|
||||
printf '\n'; warn "--- [$key version] SKIPPED (no local build-version.json - see 'Enabling deploy verification' in README) ---"
|
||||
return 0
|
||||
fi
|
||||
expected="v${pv}.$((bn + 1))"
|
||||
container="$(zproj "$key" .remote.containerName)"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
printf '\n'; info "--- [$key] Live build verification (expect $expected) ---"
|
||||
deadline=$((SECONDS + 45))
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
if [ -n "$container" ]; then
|
||||
# build-version.json may be blocked externally by the edge proxy;
|
||||
# read it inside the running container instead.
|
||||
raw="$(zssh "sudo docker exec $container cat /usr/share/nginx/html/build-version.json 2>/dev/null" 2>/dev/null)"
|
||||
else
|
||||
raw="$(http_get "http://$EC2_IP/build-version.json" "$domain")"
|
||||
fi
|
||||
live="$(printf '%s' "$raw" | jq -r '"v\(.productVersion).\(.buildNumber)"' 2>/dev/null)"
|
||||
if [ -n "$live" ] && [ "$live" != "null" ] && [ "$live" != "v." ]; then
|
||||
if [ "$live" = "$expected" ]; then
|
||||
ok " PASS - live build $live matches expected."
|
||||
return 0
|
||||
fi
|
||||
warn " Live build is $live, expected $expected - waiting..."
|
||||
else
|
||||
dim " Container not ready yet - waiting..."
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
warn " WARNING: live build did not match $expected within 45s (upload or Docker build may have failed, or a stale build is cached)."
|
||||
}
|
||||
|
||||
# Fast-forward the checkout before a deploy when deploy.gitPull is set — zdeploy
|
||||
# zips the working tree, so a stale checkout would ship stale code while still
|
||||
# bumping the build number. Abort rather than deploy uncertain code.
|
||||
deploy_git_pull() { # <key>
|
||||
local key="$1" root branch
|
||||
[ "$(zproj "$key" .deploy.gitPull)" = "true" ] || return 0
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
if [ ! -d "$root/.git" ]; then
|
||||
warn " gitPull set but '$root' is not a git repo - skipping pull."
|
||||
return 0
|
||||
fi
|
||||
printf '\n'; info "--- [0] git pull --ff-only ---"
|
||||
branch="$(cd "$root" && git rev-parse --abbrev-ref HEAD)"
|
||||
dim " Branch: $branch"
|
||||
(cd "$root" && git pull --ff-only) || {
|
||||
err "git pull --ff-only failed in '$root' (branch '$branch'). Resolve it, then re-run - refusing to deploy possibly-stale code."
|
||||
return 1
|
||||
}
|
||||
dim " Now at: $(cd "$root" && git log -1 --oneline)"
|
||||
}
|
||||
|
||||
# ── Kind handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
deploy_python() { # <key>
|
||||
local key="$1" start=$SECONDS root remote_path compose_dir app_svc zip_name zip_local
|
||||
local bv="" has_version_tool=0 excl=() attempt out label domain
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
remote_path="$(zproj "$key" .remote.path)"
|
||||
compose_dir="$(zremote_compose_dir "$key")"
|
||||
app_svc="$(zproj "$key" .remote.appService)"; [ -n "$app_svc" ] || app_svc="app"
|
||||
zip_name="$(deploy_zip_name "$key")"
|
||||
zip_local="$TEMP_ROOT/$zip_name"
|
||||
label="$(zproj "$key" .label)"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
[ -f "$root/scripts/build_version_tool.py" ] && has_version_tool=1
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
|
||||
printf '\n'; info "=== $label deploy (python) ==="
|
||||
dim "Local zip: $zip_local"
|
||||
|
||||
printf '\n'; info "--- [1] Zipping $label ---"
|
||||
find "$root" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||
mapfile -t excl < <(z_archive_excludes "$key" 0)
|
||||
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
|
||||
|
||||
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
|
||||
|
||||
printf '\n'; info "--- [2] Uploading zip ---"
|
||||
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
|
||||
rm -f "$zip_local"
|
||||
|
||||
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
|
||||
zec2_step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
|
||||
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
|
||||
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
||||
zec2_step "backup .env if present" "if [ -f $remote_path/.env ]; then cp $remote_path/.env $REMOTE_HOME/.env.${key}_bak; fi" || return 1
|
||||
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
||||
remote_unzip "$zip_name" "$remote_path" || return 1
|
||||
zec2_step "restore .env from backup" "if [ -f $REMOTE_HOME/.env.${key}_bak ]; then cp $REMOTE_HOME/.env.${key}_bak $remote_path/.env; fi" || return 1
|
||||
zec2_step "require compose directory" "test -d $compose_dir" || return 1
|
||||
zec2_step "docker compose build $app_svc" "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose build $app_svc" || return 1
|
||||
zec2_step "docker compose up -d" "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
|
||||
zec2_step "record deploy time; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
|
||||
|
||||
if [ "$has_version_tool" -eq 1 ]; then
|
||||
printf '\n'; info "--- [4] Incrementing build version ---"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
out="$(zssh "cd $compose_dir && sudo docker compose exec -T $app_svc python scripts/build_version_tool.py bump" 2>/dev/null | tail -1)"
|
||||
[ -n "$out" ] && { bv="$out"; break; }
|
||||
warn " Attempt $attempt failed, retrying in 3s..."
|
||||
sleep 3
|
||||
done
|
||||
[ -n "$bv" ] || { err "Build version bump failed after 5 attempts"; return 1; }
|
||||
|
||||
(cd "$root" && { python3 scripts/build_version_tool.py set "$bv" >/dev/null 2>&1 || python scripts/build_version_tool.py set "$bv" >/dev/null 2>&1; })
|
||||
zssh "echo '$bv' | sudo tee $remote_path/.build_version > /dev/null"
|
||||
if [ -f "$root/scripts/build_changelog_tool.py" ]; then
|
||||
(cd "$root" && { python3 scripts/build_changelog_tool.py append --version "$bv" --note "$NOTE" >/dev/null 2>&1 \
|
||||
|| python scripts/build_changelog_tool.py append --version "$bv" --note "$NOTE" >/dev/null 2>&1; })
|
||||
fi
|
||||
|
||||
printf '\n'; info "--- [5] Restarting app to pick up new version ---"
|
||||
zssh "cd $compose_dir && sudo COMPOSE_BAKE=false docker compose restart $app_svc" \
|
||||
|| { err "App restart after build bump failed"; return 1; }
|
||||
|
||||
wait_verify_api "$key" "$bv" 30 || true
|
||||
else
|
||||
printf '\n'; info "--- [4] Basic reachability check (no build_version_tool - see 'Enabling deploy verification' in README) ---"
|
||||
local deadline=$((SECONDS + 30)) code up=0
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
sleep 3
|
||||
code="$(http_code "http://$EC2_IP/" "$domain")"
|
||||
if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then up=1; break; fi
|
||||
dim " App not ready yet - waiting..."
|
||||
done
|
||||
if [ "$up" -eq 1 ]; then ok " App is responding."; else warn " WARNING: app did not respond within 30s."; fi
|
||||
fi
|
||||
|
||||
post_cleanup "$key"
|
||||
|
||||
local elapsed=$((SECONDS - start))
|
||||
printf '\n'; ok "--- [Done] $label deployed! ---"
|
||||
[ -n "$domain" ] && warn "Site: https://$domain"
|
||||
[ -n "$bv" ] && note "Build Version: $bv"
|
||||
info "Change Note: $NOTE"
|
||||
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
|
||||
}
|
||||
|
||||
deploy_vite() { # <key>
|
||||
local key="$1" start=$SECONDS root remote_path zip_name zip_local excl=()
|
||||
local pre_pv="" pre_bn="" label domain edge_pc
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
remote_path="$(zproj "$key" .remote.path)"
|
||||
zip_name="$(deploy_zip_name "$key")"
|
||||
zip_local="$TEMP_ROOT/$zip_name"
|
||||
label="$(zproj "$key" .label)"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
|
||||
printf '\n'; info "=== $label deploy (vite/static) ==="
|
||||
dim "Local zip: $zip_local"
|
||||
|
||||
printf '\n'; info "--- [1] Zipping site ---"
|
||||
if [ -f "$root/build-version.json" ]; then
|
||||
pre_pv="$(jq -r '.productVersion // empty' "$root/build-version.json")"
|
||||
pre_bn="$(jq -r '.buildNumber // empty' "$root/build-version.json")"
|
||||
[ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
|
||||
fi
|
||||
mapfile -t excl < <(z_archive_excludes "$key" 0)
|
||||
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
|
||||
|
||||
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
|
||||
|
||||
printf '\n'; info "--- [2] Uploading zip ---"
|
||||
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
|
||||
rm -f "$zip_local"
|
||||
|
||||
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
|
||||
zec2_step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
|
||||
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
|
||||
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
||||
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
||||
remote_unzip "$zip_name" "$remote_path" || return 1
|
||||
zec2_step "require compose file" "test -f $remote_path/docker-compose.yml" || return 1
|
||||
zec2_step "docker compose build" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose build" || return 1
|
||||
zec2_step "docker compose up -d" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
|
||||
|
||||
local ek; ek="$(zedge_key)"
|
||||
if [ -n "$ek" ]; then
|
||||
edge_pc="$(zproj "$ek" .proxyContainer)"
|
||||
[ -n "$edge_pc" ] && { zec2_step "reload edge nginx (flush DNS cache for new container IP)" "sudo docker exec $edge_pc nginx -s reload" || return 1; }
|
||||
fi
|
||||
zec2_step "record deploy time; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
|
||||
|
||||
wait_verify_static "$key" "$pre_pv" "$pre_bn"
|
||||
post_cleanup "$key"
|
||||
|
||||
local elapsed=$((SECONDS - start))
|
||||
printf '\n'; ok "--- [Done] $label deployed! ---"
|
||||
[ -n "$domain" ] && warn "Site: https://$domain"
|
||||
info "Change Note: $NOTE"
|
||||
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
|
||||
}
|
||||
|
||||
deploy_next() { # <key>
|
||||
local key="$1" start=$SECONDS root remote_path app_svc zip_name zip_local excl=()
|
||||
local pre_pv="" pre_bn="" label domain db_user db_name prod_port
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
remote_path="$(zproj "$key" .remote.path)"
|
||||
app_svc="$(zproj "$key" .remote.appService)"; [ -n "$app_svc" ] || app_svc="web"
|
||||
zip_name="$(deploy_zip_name "$key")"
|
||||
zip_local="$TEMP_ROOT/$zip_name"
|
||||
label="$(zproj "$key" .label)"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
|
||||
printf '\n'; info "=== $label deploy (nextjs) ==="
|
||||
dim "Local zip: $zip_local"
|
||||
|
||||
if [ -f "$root/public/build-version.json" ]; then
|
||||
pre_pv="$(jq -r '.productVersion // empty' "$root/public/build-version.json")"
|
||||
pre_bn="$(jq -r '.buildNumber // empty' "$root/public/build-version.json")"
|
||||
[ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
|
||||
fi
|
||||
|
||||
printf '\n'; info "--- [1] Zipping project files ---"
|
||||
mapfile -t excl < <(z_archive_excludes "$key" 0)
|
||||
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
|
||||
|
||||
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
|
||||
|
||||
printf '\n'; info "--- [2] Uploading zip ---"
|
||||
send_zip "$zip_local" "$zip_name" || { rm -f "$zip_local"; return 1; }
|
||||
rm -f "$zip_local"
|
||||
|
||||
printf '\n'; info "--- [3] Unzipping and rebuilding on the server ---"
|
||||
zec2_step "ensure unzip installed" "sudo apt-get update -qq && sudo apt-get install -y unzip" || return 1
|
||||
zec2_step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${EC2_USER}:${EC2_USER} $STACK_ROOT" || return 1
|
||||
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
||||
zec2_step "backup .env if present" "if [ -f $remote_path/.env ]; then cp $remote_path/.env $REMOTE_HOME/.env.${key}_bak; fi" || return 1
|
||||
zec2_step "replace project directory" "sudo rm -rf $remote_path && sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
||||
remote_unzip "$zip_name" "$remote_path" || return 1
|
||||
zec2_step "restore .env from backup" "if [ -f $REMOTE_HOME/.env.${key}_bak ]; then cp $REMOTE_HOME/.env.${key}_bak $remote_path/.env; fi" || return 1
|
||||
|
||||
printf '\n'; info "--- [4] Docker compose rebuild ---"
|
||||
zec2_step "docker compose down" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose down" || return 1
|
||||
zec2_step "docker compose build" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose build" || return 1
|
||||
zec2_step "docker compose up -d" "cd $remote_path && sudo COMPOSE_BAKE=false docker compose up -d" || return 1
|
||||
|
||||
db_user="$(zproj "$key" .db.user)"; db_name="$(zproj "$key" .db.name)"
|
||||
if [ -n "$db_user" ] && [ -n "$db_name" ]; then
|
||||
zec2_step "wait for postgres ready" \
|
||||
"cd $remote_path && for i in \$(seq 1 30); do sudo docker compose exec -T db pg_isready -U $db_user -d $db_name >/dev/null 2>&1 && break; sleep 2; done" || return 1
|
||||
fi
|
||||
if [ "$(zproj "$key" .migrations)" = "prisma" ]; then
|
||||
zec2_step "apply prisma migrations" "cd $remote_path && sudo docker compose exec -T $app_svc npx prisma migrate deploy" || return 1
|
||||
fi
|
||||
zec2_step "record deploy timestamp; remove remote zip" "date -u +'%Y-%m-%d %H:%M:%S UTC' | sudo tee $remote_path/.last_deploy_utc > /dev/null && rm -f $REMOTE_HOME/$zip_name" || return 1
|
||||
|
||||
printf '\n'; info "--- [5] Verifying deployment ---"
|
||||
prod_port="$(zproj "$key" .ports.prod)"
|
||||
if [[ "$prod_port" =~ ^[0-9]+$ ]]; then
|
||||
local direct="http://${EC2_IP}:${prod_port}/" deadline=$((SECONDS + 60)) code verified=0
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
sleep 4
|
||||
code="$(http_code "$direct")"
|
||||
if [ "$code" = "200" ]; then
|
||||
ok " PASS - app is responding at $direct"
|
||||
verified=1; break
|
||||
fi
|
||||
dim " App not ready yet - waiting..."
|
||||
done
|
||||
[ "$verified" -eq 1 ] || warn " WARNING: no response at $direct within 60s (is the port open in the security group?)."
|
||||
fi
|
||||
if [ -n "$pre_pv" ]; then
|
||||
wait_verify_api "$key" "v${pre_pv}.$((pre_bn + 1))" 60 || true
|
||||
else
|
||||
warn " (No public/build-version.json - version verification skipped. See 'Enabling deploy verification' in README.)"
|
||||
fi
|
||||
|
||||
post_cleanup "$key"
|
||||
|
||||
local elapsed=$((SECONDS - start))
|
||||
printf '\n'; ok "--- [Done] $label deployed! ---"
|
||||
[ -n "$domain" ] && warn "Site: https://$domain"
|
||||
info "Change Note: $NOTE"
|
||||
dim "Deploy Time: $(printf '%02d:%02d' $((elapsed / 60)) $((elapsed % 60))) (${elapsed}s)"
|
||||
}
|
||||
|
||||
deploy_edge() { # <key>
|
||||
local key="$1" root remote_path pc label cert_mount="" rm_stale="" f
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
remote_path="$(zproj "$key" .remote.path)"
|
||||
pc="$(zproj "$key" .proxyContainer)"
|
||||
label="$(zproj "$key" .label)"
|
||||
|
||||
printf '\n'; info "=== $label deploy (edge nginx ingress) ==="
|
||||
[ -d "$root" ] || { err "Edge root not found: $root"; return 1; }
|
||||
for f in docker-compose.yml nginx.conf; do
|
||||
[ -f "$root/$f" ] || { err "Missing $root/$f"; return 1; }
|
||||
done
|
||||
|
||||
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
||||
zec2_step "ensure edge dir" "sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
||||
|
||||
# Ship every top-level file in the edge folder — nginx.conf, compose, css,
|
||||
# htpasswd, whatever the proxy serves. Subdirectories (logs, certs) stay put.
|
||||
while IFS= read -r -d '' f; do
|
||||
info " >> uploading $(basename "$f")"
|
||||
scp -i "$(zec2_pem)" "$f" "$(zec2_target):$remote_path/" \
|
||||
|| { err "SCP failed for $(basename "$f")"; return 1; }
|
||||
done < <(find "$root" -maxdepth 1 -type f ! -name nul -print0)
|
||||
|
||||
local certs; certs="$(zproj "$key" .certsSource)"
|
||||
[ -n "$certs" ] && cert_mount="-v $certs:/etc/letsencrypt/:ro "
|
||||
zec2_step "validate new nginx.conf" \
|
||||
"sudo docker run --rm -v $remote_path/nginx.conf:/etc/nginx/nginx.conf:ro ${cert_mount}nginx:1.27-alpine nginx -t -c /etc/nginx/nginx.conf" || return 1
|
||||
|
||||
# A container from an older compose project may still hold the proxy name;
|
||||
# docker refuses a second create with the same name, so remove it first.
|
||||
[ -n "$pc" ] && rm_stale="; sudo docker rm -f $pc 2>/dev/null || true"
|
||||
zec2_step "edge: compose down + remove stale proxy" "cd $remote_path && sudo docker compose down 2>/dev/null || true$rm_stale" || return 1
|
||||
zec2_step "edge compose up -d" "cd $remote_path && sudo docker compose up -d" || return 1
|
||||
[ -n "$pc" ] && { zec2_step "edge nginx reload" "sudo docker exec $pc nginx -s reload || true" || return 1; }
|
||||
zec2_step "fix nginx-logs permissions (if present)" \
|
||||
"if [ -d $remote_path/nginx-logs ]; then sudo chmod 777 $remote_path/nginx-logs; sudo chmod 666 $remote_path/nginx-logs/*.log 2>/dev/null || true; fi" || return 1
|
||||
ok "--- [Done] Edge proxy deploy finished ---"
|
||||
}
|
||||
|
||||
deploy_docker() { # <key>
|
||||
local key="$1" root remote_path label f domain
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
remote_path="$(zproj "$key" .remote.path)"
|
||||
label="$(zproj "$key" .label)"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
|
||||
printf '\n'; info "=== $label deploy (docker compose) ==="
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
[ -f "$root/docker-compose.yml" ] || { err "Missing $root/docker-compose.yml"; return 1; }
|
||||
|
||||
zec2_step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" || return 1
|
||||
zec2_step "ensure project dir" "sudo mkdir -p $remote_path && sudo chown ${EC2_USER}:${EC2_USER} $remote_path" || return 1
|
||||
|
||||
while IFS= read -r -d '' f; do
|
||||
info " >> uploading $(basename "$f")"
|
||||
scp -i "$(zec2_pem)" "$f" "$(zec2_target):$remote_path/" \
|
||||
|| { err "SCP failed for $(basename "$f")"; return 1; }
|
||||
done < <(find "$root" -maxdepth 1 -type f ! -name nul -print0)
|
||||
|
||||
zec2_step "docker compose pull" "cd $remote_path && sudo docker compose pull" || return 1
|
||||
zec2_step "docker compose up -d" "cd $remote_path && sudo docker compose up -d" || return 1
|
||||
|
||||
post_cleanup "$key"
|
||||
printf '\n'; ok "--- [Done] $label deploy finished ---"
|
||||
[ -n "$domain" ] && warn "Site: https://$domain"
|
||||
}
|
||||
|
||||
# ── Dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
for key in "${projects[@]}"; do
|
||||
zproj_require "$key"
|
||||
deploy_git_pull "$key" || exit 1
|
||||
kind="$(zproj "$key" .kind)"
|
||||
case "$kind" in
|
||||
python) deploy_python "$key" || exit 1 ;;
|
||||
vite) deploy_vite "$key" || exit 1 ;;
|
||||
nextjs) deploy_next "$key" || exit 1 ;;
|
||||
edge) deploy_edge "$key" || exit 1 ;;
|
||||
docker) deploy_docker "$key" || exit 1 ;;
|
||||
*) err "No deploy handler for kind '$kind' (project '$key'). Add a deploy_<kind> function in zdeploy."; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
94
bash/zec2
Normal file
94
bash/zec2
Normal file
@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zec2 — quick reachability check (TCP + HTTP + live build version) for deployed projects.
|
||||
#
|
||||
# Usage:
|
||||
# zec2 # every project with a "domain" in zconfig.json
|
||||
# zec2 <project> [<project> ...]
|
||||
# zec2 viteapp --host 203.0.113.10
|
||||
|
||||
set -uo pipefail
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
|
||||
z_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
|
||||
mapfile -t projects < <(zproj_keys_with_domain)
|
||||
if [ "${#projects[@]}" -eq 0 ]; then
|
||||
warn "No projects with a 'domain' configured in zconfig.json."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
check_reachability() { # <label> <port> <host-header>
|
||||
local label="$1" port="$2" hh="$3" code
|
||||
printf '\n'; info "=== zec2: $label ==="
|
||||
dim " Target: ${HOST}:${port}${hh:+ (Host: $hh)}"
|
||||
printf '\n'
|
||||
|
||||
warn " [1/2] TCP connect to port ${port}..."
|
||||
if tcp_check "$HOST" "$port"; then
|
||||
ok " OK - port ${port} is open (TCP succeeded)"
|
||||
else
|
||||
err " FAIL - port ${port} did not accept TCP (check firewall/security group + app on the server)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
warn " [2/2] HTTP GET http://${HOST}:${port}/ ..."
|
||||
code="$(http_code "http://${HOST}:${port}/" "$hh")"
|
||||
if [ "$code" = "000" ]; then
|
||||
err " FAIL - no HTTP response (connection dropped or timed out)"
|
||||
return 1
|
||||
elif [ "$code" -lt 400 ]; then
|
||||
ok " OK - HTTP $code"
|
||||
else
|
||||
warn " HTTP response: $code (connection worked; app may redirect or require auth)"
|
||||
fi
|
||||
|
||||
printf '\n'; info " Done ($label)."
|
||||
return 0
|
||||
}
|
||||
|
||||
show_live_version() { # <key>
|
||||
local label
|
||||
label="$(remote_version_label "$1" "$HOST")"
|
||||
[ "$label" != "unknown" ] && dim " Live build: $label" \
|
||||
|| dim " (Could not read live version endpoint - see 'Enabling deploy verification' in README)"
|
||||
}
|
||||
|
||||
exit_code=0
|
||||
for key in "${projects[@]}"; do
|
||||
zproj_require "$key"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
if [ -z "$domain" ]; then
|
||||
printf '\n'; warn "Skipping '$key' - no domain configured."
|
||||
continue
|
||||
fi
|
||||
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
|
||||
if check_reachability "$label" 80 "$domain"; then
|
||||
show_live_version "$key"
|
||||
else
|
||||
exit_code=1
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n'
|
||||
info "If TCP fails: open inbound TCP in the server's firewall/security group."
|
||||
info "If TCP OK but HTTP fails: SSH in and check docker/nginx (curl -sI http://127.0.0.1/)."
|
||||
printf '\n'
|
||||
exit "$exit_code"
|
||||
149
bash/zec2online
Normal file
149
bash/zec2online
Normal file
@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zec2online — deep health check: verify apps are live AND running the expected
|
||||
# build; auto-start downed stacks via docker compose and stream diagnostics.
|
||||
#
|
||||
# Usage:
|
||||
# zec2online # every project with a "domain" in zconfig.json
|
||||
# zec2online <project> [<project> ...]
|
||||
#
|
||||
# A plain HTTP-200 check passes even when a stale cached build is live — the
|
||||
# local-vs-server version match is what proves the deployed build is the one running.
|
||||
|
||||
set -uo pipefail
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
|
||||
z_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
|
||||
mapfile -t projects < <(zproj_keys_with_domain)
|
||||
if [ "${#projects[@]}" -eq 0 ]; then
|
||||
warn "No projects with a 'domain' configured in zconfig.json."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
compare_versions() { # <key> <local> <server>
|
||||
local key="$1" local_v="$2" server_v="$3"
|
||||
if [ "$local_v" = "unknown" ] && [ "$server_v" = "unknown" ]; then
|
||||
dim " Version: unable to determine local or server version (see 'Enabling deploy verification' in README)"
|
||||
elif [ "$local_v" = "unknown" ]; then
|
||||
note " Server: $server_v"; dim " Local: unknown (could not read local build version)"
|
||||
elif [ "$server_v" = "unknown" ]; then
|
||||
note " Local: $local_v"; dim " Server: unknown (could not read server build version)"
|
||||
elif [ "$local_v" = "$server_v" ]; then
|
||||
ok " Version: $server_v (local and server match)"
|
||||
else
|
||||
note " Local: $local_v"; note " Server: $server_v"
|
||||
warn " WARNING: local and server versions differ - redeploy with: zdeploy $key"
|
||||
fi
|
||||
}
|
||||
|
||||
test_http_online() { # <host-header>
|
||||
local code
|
||||
code="$(http_code "http://${HOST}/" "$1")"
|
||||
if [ "$code" = "000" ]; then return 1; fi
|
||||
if [ "$code" -lt 500 ]; then
|
||||
if [ "$code" -ge 300 ]; then dim " HTTP $code - redirect from app, server is live."
|
||||
else dim " HTTP $code - service reachable."; fi
|
||||
return 0
|
||||
fi
|
||||
warn " HTTP $code - unexpected server response."
|
||||
return 1
|
||||
}
|
||||
|
||||
show_diagnostics() { # <key> <reason>
|
||||
local key="$1" reason="$2" compose_dir cmd pc
|
||||
[ -f "$(zec2_pem)" ] || { dim " Diagnostics skipped: PEM key not found at $(zec2_pem)"; return; }
|
||||
compose_dir="$(zremote_compose_dir "$key")"
|
||||
printf '\n'; note " --- $key diagnostics ($reason) ---"
|
||||
cmd="echo '== services =='; cd $compose_dir 2>/dev/null && sudo docker compose ps"
|
||||
cmd+="; echo '== uptime / df =='; uptime; df -h /"
|
||||
cmd+="; echo '== oom =='; dmesg -T 2>/dev/null | grep -iE 'oom|killed' | tail -n 10"
|
||||
cmd+="; echo '== app logs (80) =='; cd $compose_dir 2>/dev/null && sudo docker compose logs --tail 80"
|
||||
if [ -n "$EDGE_KEY" ]; then
|
||||
pc="$(zproj "$EDGE_KEY" .proxyContainer)"
|
||||
if [ -n "$pc" ]; then
|
||||
cmd+="; echo '== edge proxy state =='; sudo docker ps --filter name=$pc --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
cmd+="; echo '== edge proxy logs (40) =='; sudo docker logs --tail 40 $pc 2>&1 | tail -n 40"
|
||||
fi
|
||||
fi
|
||||
zssh "$cmd" || warn " Diagnostics SSH failed."
|
||||
note " --- end $key diagnostics ---"; printf '\n'
|
||||
}
|
||||
|
||||
online_check() { # <key>
|
||||
local key="$1" label domain compose_dir start_cmd edge_dir deadline
|
||||
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
|
||||
domain="$(zproj "$key" .domain)"
|
||||
|
||||
printf '\n'; info "=== zec2online: $label ($key) ==="
|
||||
dim " Target: http://${HOST}/ (Host: $domain)"
|
||||
printf '\n'
|
||||
|
||||
warn ' [1] Checking TCP + HTTP...'
|
||||
if test_http_online "$domain"; then
|
||||
compare_versions "$key" "$(local_version_label "$key")" "$(remote_version_label "$key" "$HOST")"
|
||||
ok " UP - $label is running."
|
||||
printf '\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
err ' DOWN - Service not responding.'
|
||||
show_diagnostics "$key" 'DOWN before recovery'
|
||||
|
||||
warn ' [2] Starting stack (and edge proxy if defined)...'
|
||||
compose_dir="$(zremote_compose_dir "$key")"
|
||||
[ -f "$(zec2_pem)" ] || { err " Cannot start: PEM key not found at: $(zec2_pem)"; return 1; }
|
||||
start_cmd="sudo docker network create web 2>/dev/null || true"
|
||||
start_cmd+="; if [ -f $compose_dir/docker-compose.yml ]; then cd $compose_dir && sudo docker compose up -d 2>&1 | tail -10; else echo 'compose missing at $compose_dir'; exit 2; fi"
|
||||
if [ -n "$EDGE_KEY" ]; then
|
||||
edge_dir="$(zproj "$EDGE_KEY" .remote.path)"
|
||||
[ -n "$edge_dir" ] && start_cmd+="; if [ -f $edge_dir/docker-compose.yml ]; then cd $edge_dir && sudo docker compose up -d 2>&1 | tail -10; fi"
|
||||
fi
|
||||
zssh "$start_cmd" || { err " SSH failed. Check connectivity and PEM key."; return 1; }
|
||||
|
||||
printf '\n'; warn ' [3] Waiting for service (up to 30s)...'
|
||||
deadline=$((SECONDS + 30))
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
sleep 3
|
||||
dim ' checking...'
|
||||
if test_http_online "$domain"; then
|
||||
printf '\n'
|
||||
compare_versions "$key" "$(local_version_label "$key")" "$(remote_version_label "$key" "$HOST")"
|
||||
ok " UP - $label is now running."
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n'; err ' TIMEOUT - Service did not respond within 30 seconds.'
|
||||
show_diagnostics "$key" 'recovery TIMEOUT'
|
||||
return 1
|
||||
}
|
||||
|
||||
exit_code=0
|
||||
for key in "${projects[@]}"; do
|
||||
zproj_require "$key"
|
||||
if [ -z "$(zproj "$key" .domain)" ]; then
|
||||
printf '\n'; warn "Skipping '$key' - no domain configured."
|
||||
continue
|
||||
fi
|
||||
online_check "$key" || exit_code=1
|
||||
done
|
||||
exit "$exit_code"
|
||||
313
bash/zhelpers.sh
Normal file
313
bash/zhelpers.sh
Normal file
@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zhelpers.sh — shared library sourced by every z-script (bash port). Not run directly.
|
||||
|
||||
# ---- config location --------------------------------------------------------
|
||||
_ZDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# zconfig.json lives next to the scripts; override with $ZCONFIG.
|
||||
ZCONFIG="${ZCONFIG:-$_ZDIR/zconfig.json}"
|
||||
|
||||
# ---- colours (only when stdout is a TTY) ------------------------------------
|
||||
if [ -t 1 ]; then
|
||||
C_RESET=$'\033[0m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_GRAY=$'\033[90m'; C_MAGENTA=$'\033[35m'
|
||||
else
|
||||
C_RESET= C_CYAN= C_GREEN= C_YELLOW= C_RED= C_GRAY= C_MAGENTA=
|
||||
fi
|
||||
info() { printf '%s%s%s\n' "$C_CYAN" "$*" "$C_RESET"; }
|
||||
ok() { printf '%s%s%s\n' "$C_GREEN" "$*" "$C_RESET"; }
|
||||
warn() { printf '%s%s%s\n' "$C_YELLOW" "$*" "$C_RESET"; }
|
||||
err() { printf '%s%s%s\n' "$C_RED" "$*" "$C_RESET" >&2; }
|
||||
dim() { printf '%s%s%s\n' "$C_GRAY" "$*" "$C_RESET"; }
|
||||
note() { printf '%s%s%s\n' "$C_MAGENTA" "$*" "$C_RESET"; }
|
||||
|
||||
# ---- dependency guard -------------------------------------------------------
|
||||
z_require() {
|
||||
local miss=0 t
|
||||
for t in "$@"; do
|
||||
command -v "$t" >/dev/null 2>&1 || { err "Missing required tool: $t"; miss=1; }
|
||||
done
|
||||
[ "$miss" -eq 0 ] || exit 1
|
||||
}
|
||||
|
||||
# ---- config accessors (jq) --------------------------------------------------
|
||||
z_need_config() {
|
||||
z_require jq
|
||||
if [ ! -f "$ZCONFIG" ]; then
|
||||
err "zconfig.json not found at $ZCONFIG"
|
||||
dim " Copy zconfig.example.json to zconfig.json and fill in your values."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
zq() { jq -r "$1" "$ZCONFIG"; }
|
||||
# All project keys, config order, skipping _comment-style keys.
|
||||
zproj_keys() { zq '.projects | keys_unsorted[] | select(startswith("_") | not)'; }
|
||||
zproj_csv() { zproj_keys | paste -sd',' -; }
|
||||
# Keys of projects that have a "domain" (deployed / publicly reachable).
|
||||
zproj_keys_with_domain() {
|
||||
jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.domain != null) | .key' "$ZCONFIG"
|
||||
}
|
||||
# Field of a project: zproj <key> <dotpath> e.g. zproj sp .kind | zproj sp .ports.dev
|
||||
zproj() { jq -r --arg k "$1" ".projects[\$k]$2 // empty" "$ZCONFIG"; }
|
||||
zproj_require() {
|
||||
if [ "$(jq -r --arg k "$1" '(.projects[$k] != null)' "$ZCONFIG")" != "true" ]; then
|
||||
err "Unknown project key '$1'. Available: $(zproj_csv)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
zec2_ip() { zq '.ec2.ip'; }
|
||||
zec2_user() { zq '.ec2.user'; }
|
||||
zec2_pem() { zq '.ec2.pemKey'; }
|
||||
zec2_target() { printf '%s@%s' "$(zec2_user)" "$(zec2_ip)"; }
|
||||
# Remote compose directory: remote.composeDir if set, else remote.path.
|
||||
zremote_compose_dir() {
|
||||
local d
|
||||
d="$(zproj "$1" .remote.composeDir)"
|
||||
[ -n "$d" ] || d="$(zproj "$1" .remote.path)"
|
||||
printf '%s' "$d"
|
||||
}
|
||||
# First project of kind 'edge' (or empty).
|
||||
zedge_key() {
|
||||
jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG" | head -1
|
||||
}
|
||||
|
||||
# ---- SSH helpers ------------------------------------------------------------
|
||||
# Run a bash command on the server. zssh is bare; zec2_step adds a label and
|
||||
# exits non-zero loudly (mirrors Invoke-Ec2Step).
|
||||
zssh() {
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 -i "$(zec2_pem)" "$(zec2_target)" "$@"
|
||||
}
|
||||
zec2_step() {
|
||||
local label="$1"; shift
|
||||
dim " >> $label"
|
||||
zssh "$@"
|
||||
local rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
err "Remote step failed: '$label' (exit $rc)."
|
||||
return $rc
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- HTTP / TCP helpers -----------------------------------------------------
|
||||
# HTTP status code for a URL with optional Host header ("000" on connect fail).
|
||||
http_code() {
|
||||
local url="$1" host_header="${2:-}" args=(-s -o /dev/null -w '%{http_code}' -L --max-time 15)
|
||||
[ -n "$host_header" ] && args+=(-H "Host: $host_header")
|
||||
curl "${args[@]}" "$url" 2>/dev/null || printf '000'
|
||||
}
|
||||
# GET a URL body (with Host header); empty on failure.
|
||||
http_get() {
|
||||
local url="$1" host_header="${2:-}" args=(-s --max-time 10)
|
||||
[ -n "$host_header" ] && args+=(-H "Host: $host_header")
|
||||
curl "${args[@]}" "$url" 2>/dev/null
|
||||
}
|
||||
# TCP connect check via bash /dev/tcp (uses `timeout` when available).
|
||||
tcp_check() {
|
||||
local host="$1" port="$2"
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout 5 bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null
|
||||
else
|
||||
bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- build-version helpers --------------------------------------------------
|
||||
# "v<productVersion>.<buildNumber>" from a build-version.json file, or "unknown".
|
||||
json_build_label() {
|
||||
local f="$1"
|
||||
[ -f "$f" ] || { printf 'unknown'; return; }
|
||||
jq -r '"v\(.productVersion).\(.buildNumber)"' "$f" 2>/dev/null || printf 'unknown'
|
||||
}
|
||||
# Local build label by project kind (mirrors Get-LocalVersionLabel).
|
||||
local_version_label() {
|
||||
local key="$1" kind root out
|
||||
kind="$(zproj "$key" .kind)"; root="$(zproj "$key" .localRoot)"
|
||||
case "$kind" in
|
||||
python)
|
||||
if [ -f "$root/scripts/build_version_tool.py" ]; then
|
||||
out="$(cd "$root" && { python3 scripts/build_version_tool.py get 2>/dev/null || python scripts/build_version_tool.py get 2>/dev/null; } | tail -1)"
|
||||
[ -n "$out" ] && { printf '%s' "$out"; return; }
|
||||
fi
|
||||
[ -f "$root/.build_version" ] && { tail -1 "$root/.build_version" | tr -d '[:space:]'; return; }
|
||||
printf 'unknown' ;;
|
||||
vite) json_build_label "$root/build-version.json" ;;
|
||||
nextjs) json_build_label "$root/public/build-version.json" ;;
|
||||
*) printf 'unknown' ;;
|
||||
esac
|
||||
}
|
||||
# Live/server build label by kind (HTTP endpoints; python falls back to SSH).
|
||||
remote_version_label() {
|
||||
local key="$1" host="$2" kind domain body label
|
||||
kind="$(zproj "$key" .kind)"; domain="$(zproj "$key" .domain)"
|
||||
if [ "$kind" = "vite" ]; then
|
||||
body="$(http_get "http://$host/build-version.json" "$domain")"
|
||||
label="$(printf '%s' "$body" | jq -r '"v\(.productVersion).\(.buildNumber)"' 2>/dev/null)"
|
||||
[ -n "$label" ] && [ "$label" != "null" ] && { printf '%s' "$label"; return; }
|
||||
else
|
||||
body="$(http_get "http://$host/api/build-version" "$domain")"
|
||||
label="$(printf '%s' "$body" | jq -r '.build_version // empty' 2>/dev/null)"
|
||||
[ -n "$label" ] && { printf '%s' "$label"; return; }
|
||||
if [ "$kind" = "python" ] && [ -f "$(zec2_pem)" ]; then
|
||||
label="$(zssh "cat $(zproj "$key" .remote.path)/.build_version 2>/dev/null || true" 2>/dev/null | tail -1 | tr -d '[:space:]')"
|
||||
[ -n "$label" ] && { printf '%s' "$label"; return; }
|
||||
fi
|
||||
fi
|
||||
printf 'unknown'
|
||||
}
|
||||
|
||||
# ---- MOTD -------------------------------------------------------------------
|
||||
# Print a random banner from <root>/motd/*.txt (the PowerShell version keeps a
|
||||
# shuffled rotation state; the bash port picks at random — same spirit, simpler).
|
||||
show_project_motd() {
|
||||
local root="$1" files=()
|
||||
[ -d "$root/motd" ] || return 0
|
||||
while IFS= read -r -d '' f; do files+=("$f"); done \
|
||||
< <(find "$root/motd" -maxdepth 1 -name '*.txt' -type f -print0 2>/dev/null)
|
||||
[ "${#files[@]}" -gt 0 ] || return 0
|
||||
printf '\n'; cat "${files[RANDOM % ${#files[@]}]}"; printf '\n'
|
||||
}
|
||||
|
||||
# ---- archive builder --------------------------------------------------------
|
||||
# Junk filtered out of every deploy/backup zip (mirrors the PowerShell lists).
|
||||
_Z_JUNK_DIRS=(.git .svn .hg __pycache__ .pytest_cache .mypy_cache .ruff_cache .tox
|
||||
node_modules .npm .yarn .pnpm-store .next .nuxt .svelte-kit .turbo .parcel-cache
|
||||
.cache .idea .vscode coverage htmlcov .nyc_output)
|
||||
_Z_JUNK_EXTS=(swp swo swn tmp orig rej bak backup old pyc pyo pyd tsbuildinfo log
|
||||
db sqlite sqlite3 zip dmg arj gz tgz tar rar 7z iso bz2 xz lz lzma cab jar war ear z zst zstd)
|
||||
_Z_SCRIPT_EXTS=(ps1 cmd bat)
|
||||
_Z_JUNK_NAMES=(.DS_Store Thumbs.db desktop.ini)
|
||||
|
||||
# Top-level exclude names for a project's archive (mirrors Get-ArchiveExcludes).
|
||||
# Usage: z_archive_excludes <key> [for_backup:0|1] -> one name per line
|
||||
z_archive_excludes() {
|
||||
local key="$1" for_backup="${2:-0}" kind
|
||||
kind="$(zproj "$key" .kind)"
|
||||
printf '%s\n' .git .idea .vscode .claude tmp nul .DS_Store backups
|
||||
case "$kind" in
|
||||
python)
|
||||
printf '%s\n' .venv venv __pycache__ .pytest_cache .nicegui archive dist build htmlcov
|
||||
[ "$for_backup" -eq 1 ] || printf '%s\n' uploads ;; # deploys exclude user uploads; backups keep them
|
||||
vite) printf '%s\n' node_modules dist ;;
|
||||
nextjs) printf '%s\n' node_modules .next .env .env.local .env.production .vercel coverage out build next-env.d.ts ;;
|
||||
esac
|
||||
jq -r --arg k "$key" '.projects[$k].deploy.exclude // [] | .[]' "$ZCONFIG"
|
||||
}
|
||||
|
||||
# Build a zip from a source directory.
|
||||
# Usage: z_archive <src_dir> <dest_zip> <include_scripts:0|1> <extra_file|-> [top_excludes...]
|
||||
z_archive() {
|
||||
local src="$1" dest="$2" include_scripts="$3" extra="$4"; shift 4
|
||||
z_require zip
|
||||
[ -d "$src" ] || { err "Source path is not a directory: $src"; return 1; }
|
||||
rm -f "$dest"; mkdir -p "$(dirname "$dest")"
|
||||
|
||||
local pat=() e n
|
||||
for n in "$@"; do [ -n "$n" ] && pat+=("$n" "$n/*"); done
|
||||
for n in "${_Z_JUNK_DIRS[@]}"; do pat+=("$n/*" "*/$n/*"); done
|
||||
for e in "${_Z_JUNK_EXTS[@]}"; do pat+=("*.${e}"); done
|
||||
if [ "$include_scripts" -ne 1 ]; then
|
||||
for e in "${_Z_SCRIPT_EXTS[@]}"; do pat+=("*.${e}"); done
|
||||
fi
|
||||
for n in "${_Z_JUNK_NAMES[@]}"; do pat+=("$n" "*/$n"); done
|
||||
|
||||
( cd "$src" && zip -rq "$dest" . -x "${pat[@]}" ) || { err "zip failed for $src"; return 1; }
|
||||
if [ -n "$extra" ] && [ "$extra" != "-" ] && [ -f "$extra" ]; then
|
||||
zip -jq "$dest" "$extra" || warn " could not add extra file: $extra"
|
||||
fi
|
||||
dim " Archive: $dest ($(z_size_mb "$dest") MB)"
|
||||
}
|
||||
|
||||
# File size in MB (portable: bytes via wc).
|
||||
z_size_mb() { awk -v b="$(wc -c < "$1" 2>/dev/null || echo 0)" 'BEGIN{printf "%.2f", b/1048576}'; }
|
||||
|
||||
# Percent-decode a URL component (for DATABASE_URL passwords).
|
||||
z_urldecode() { local d="${1//+/ }"; printf '%b' "${d//%/\\x}"; }
|
||||
|
||||
# ---- port operations (Linux/macOS: needs lsof) ------------------------------
|
||||
# Kill processes LISTENING on a TCP port. Progress -> stderr; killed count -> stdout.
|
||||
kill_listeners() {
|
||||
local port="$1" pids pid n=0
|
||||
pids="$(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null | sort -u)"
|
||||
for pid in $pids; do
|
||||
dim " Killing PID $pid (port $port)" >&2
|
||||
if kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null; then n=$((n + 1)); fi
|
||||
done
|
||||
printf '%s' "$n"
|
||||
}
|
||||
|
||||
# ---- 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 (avoids locale-dependent printf %'d).
|
||||
_z_commafy() { printf '%s' "$1" | sed -E ':a;s/([0-9])([0-9]{3})($|[^0-9])/\1,\2\3/;ta'; }
|
||||
|
||||
# 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="$(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
|
||||
}
|
||||
60
bash/zkill
Normal file
60
bash/zkill
Normal file
@ -0,0 +1,60 @@
|
||||
#!/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> ...] [--port N] [--kill-all]"
|
||||
dim " Projects in zconfig.json: $(zproj_csv)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for key in "${projects[@]}"; do
|
||||
zproj_require "$key"
|
||||
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"
|
||||
|
||||
if [ "$port_override" -gt 0 ] 2>/dev/null; then
|
||||
port="$port_override"
|
||||
else
|
||||
port="$(zproj "$key" .ports.dev)"
|
||||
fi
|
||||
|
||||
printf '\n'; info "=== zkill ($label) ==="
|
||||
if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -gt 0 ]; then
|
||||
dim "Port: $port"
|
||||
killed="$(kill_listeners "$port")"
|
||||
else
|
||||
warn "No dev port configured for '$key' - skipping port kill."
|
||||
killed=0
|
||||
fi
|
||||
|
||||
# TODO (parity with ZKillOnly.ps1 -KillAll): also stop stray project processes.
|
||||
if [ "${killed:-0}" -gt 0 ]; then
|
||||
note " $killed process(es) stopped"
|
||||
else
|
||||
ok " Nothing to kill"
|
||||
fi
|
||||
done
|
||||
printf '\n'; info "Kill complete."
|
||||
80
bash/zrepair
Normal file
80
bash/zrepair
Normal file
@ -0,0 +1,80 @@
|
||||
#!/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 https://$domain..."
|
||||
code="$(http_code "http://$HOST/" "$domain")"
|
||||
if [ "$code" = "000" ]; then
|
||||
err " FAIL - smoke test failed: no HTTP response"
|
||||
elif [ "$code" -lt 400 ]; then
|
||||
ok " PASS - responded with HTTP $code (Online)"
|
||||
elif [ "$code" -lt 500 ]; then
|
||||
ok " PASS - responded with HTTP $code (Online/Redirect or auth)"
|
||||
else
|
||||
warn " WARNING - unexpected HTTP $code"
|
||||
fi
|
||||
else
|
||||
warn " [3/3] No domain configured - skipping smoke test."
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n'; ok "=== Repair & diagnostics completed ==="
|
||||
printf '\n'
|
||||
54
bash/zrestart
Normal file
54
bash/zrestart
Normal file
@ -0,0 +1,54 @@
|
||||
#!/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
|
||||
107
bash/zsetup_mail
Normal file
107
bash/zsetup_mail
Normal file
@ -0,0 +1,107 @@
|
||||
#!/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!"
|
||||
176
bash/zstart
Normal file
176
bash/zstart
Normal file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
||||
# Created by Kelly Michels · dev@evomedia.net
|
||||
# Licensed under the MIT License. See LICENSE.
|
||||
#
|
||||
# zstart — start local dev servers for any project defined in zconfig.json.
|
||||
#
|
||||
# Usage:
|
||||
# zstart <project> [<project> ...] [--port N] [--bind-host host] [--detached]
|
||||
#
|
||||
# Handlers by kind: python (python -m <startModule>, prefers .venv),
|
||||
# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT).
|
||||
# Detached servers log to /tmp/zstart-<project>.log ; stop them with zkill.
|
||||
|
||||
set -uo pipefail
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zhelpers.sh"
|
||||
z_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"
|
||||
( cd "$dir" && nohup "$@" >"$log" 2>&1 & )
|
||||
ok "Started in detached mode (logs: $log)."
|
||||
dim "Use zkill $key to stop it."
|
||||
}
|
||||
|
||||
# Optional per-project pre-start steps from zconfig.json:
|
||||
# "start": { "gitPull": true, "env": { "SOME_FLAG": "1" } }
|
||||
start_prep() { # <key>
|
||||
local key="$1" root kv name val
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
while IFS=$'\t' read -r name val; do
|
||||
[ -n "$name" ] || continue
|
||||
export "$name=$val"
|
||||
dim " env $name=$val"
|
||||
done < <(jq -r --arg k "$key" '.projects[$k].start.env // {} | to_entries[] | "\(.key)\t\(.value)"' "$ZCONFIG")
|
||||
if [ "$(zproj "$key" .start.gitPull)" = "true" ] && [ -d "$root/.git" ]; then
|
||||
local last
|
||||
last="$( (cd "$root" && git pull --ff-only 2>&1) | tail -1 )"
|
||||
dim " git pull: $last"
|
||||
(cd "$root" && git rev-parse HEAD >/dev/null 2>&1) || warn " Auto-pull failed - run 'git pull' manually if needed."
|
||||
fi
|
||||
}
|
||||
|
||||
start_python() { # <key> <port>
|
||||
local key="$1" port="$2" root module exe bv=""
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
module="$(zproj "$key" .startModule)"
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
[ -n "$module" ] || { err "Project '$key' (kind=python) needs 'startModule' in zconfig.json (e.g. \"startModule\": \"pyapp.main\")."; return 1; }
|
||||
|
||||
# Prefer the project venv. 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"
|
||||
else exe="python"; fi
|
||||
|
||||
# Optional convention: scripts/build_version_tool.py bumps the version on dev start.
|
||||
if [ -f "$root/scripts/build_version_tool.py" ]; then
|
||||
bv="$(cd "$root" && "$exe" scripts/build_version_tool.py bump 2>/dev/null | tail -1)"
|
||||
[ -n "$bv" ] || bv="$(cd "$root" && "$exe" scripts/build_version_tool.py get 2>/dev/null | tail -1)"
|
||||
fi
|
||||
|
||||
printf '\n'; info "=== zstart ($(zproj "$key" .label)) ==="
|
||||
info "Starting python -m $module on port $port..."
|
||||
[ -n "$bv" ] && note "Build Version: $bv"
|
||||
show_project_motd "$root"
|
||||
dim "Press Ctrl+C to stop the server"
|
||||
warn_if_privileged_port "$port"
|
||||
printf '\n'
|
||||
|
||||
if [ "$detached" -eq 1 ]; then
|
||||
detach "$key" "$root" "$exe" -m "$module"
|
||||
else
|
||||
( cd "$root" && exec "$exe" -m "$module" )
|
||||
fi
|
||||
}
|
||||
|
||||
start_vite() { # <key> <port>
|
||||
local key="$1" port="$2" root
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
[ -f "$root/package.json" ] || { err "package.json not found in $root"; return 1; }
|
||||
|
||||
printf '\n'; info "=== zstart ($(zproj "$key" .label) - Vite) ==="
|
||||
dim "Directory: $root"
|
||||
info "URL: http://${bind_host}:${port}/"
|
||||
printf '\n'
|
||||
|
||||
if [ ! -d "$root/node_modules" ]; then
|
||||
warn "node_modules missing - running npm install..."
|
||||
(cd "$root" && npm install) || { err "npm install failed"; return 1; }
|
||||
fi
|
||||
|
||||
show_project_motd "$root"
|
||||
dim "Press Ctrl+C to stop the dev server"
|
||||
warn_if_privileged_port "$port"
|
||||
printf '\n'
|
||||
|
||||
if [ "$detached" -eq 1 ]; then
|
||||
detach "$key" "$root" npm run dev -- --host "$bind_host" --port "$port"
|
||||
else
|
||||
( cd "$root" && exec npm run dev -- --host "$bind_host" --port "$port" )
|
||||
fi
|
||||
}
|
||||
|
||||
start_next() { # <key> <port>
|
||||
local key="$1" port="$2" root
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
[ -d "$root" ] || { err "Project root not found: $root"; return 1; }
|
||||
[ -f "$root/package.json" ] || { err "package.json not found in $root"; return 1; }
|
||||
|
||||
printf '\n'; info "=== zstart ($(zproj "$key" .label) - Next.js) ==="
|
||||
info "Starting on port $port..."
|
||||
printf '\n'
|
||||
show_project_motd "$root"
|
||||
dim "Press Ctrl+C to stop the server"
|
||||
warn_if_privileged_port "$port"
|
||||
printf '\n'
|
||||
|
||||
export PORT="$port"
|
||||
if [ "$detached" -eq 1 ]; then
|
||||
detach "$key" "$root" npm run dev
|
||||
else
|
||||
( cd "$root" && exec npm run dev )
|
||||
fi
|
||||
}
|
||||
|
||||
rc=0
|
||||
for key in "${projects[@]}"; do
|
||||
zproj_require "$key"
|
||||
kind="$(zproj "$key" .kind)"
|
||||
if [ "$port_override" -gt 0 ] 2>/dev/null; then port="$port_override"
|
||||
else port="$(zproj "$key" .ports.dev)"; [[ "$port" =~ ^[0-9]+$ ]] || port=3000; fi
|
||||
|
||||
start_prep "$key"
|
||||
|
||||
case "$kind" in
|
||||
python) start_python "$key" "$port" || rc=1 ;;
|
||||
vite) start_vite "$key" "$port" || rc=1 ;;
|
||||
nextjs) start_next "$key" "$port" || rc=1 ;;
|
||||
*)
|
||||
printf '\n'
|
||||
warn "Project '$key' has kind '$kind' - no local dev server to start."
|
||||
dim "Supported kinds for zstart: python, vite, nextjs"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit "$rc"
|
||||
66
bash/zstart_docker
Normal file
66
bash/zstart_docker
Normal file
@ -0,0 +1,66 @@
|
||||
#!/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
|
||||
38
bash/zstop
Normal file
38
bash/zstop
Normal file
@ -0,0 +1,38 @@
|
||||
#!/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."
|
||||
97
bash/zsync
Normal file
97
bash/zsync
Normal file
@ -0,0 +1,97 @@
|
||||
#!/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 "$(zq '.paths.backupsLocal')")"
|
||||
OFFSITE_ROOT="$(zq '.paths.oneDriveBackups')"
|
||||
|
||||
printf '\n'; info "=== zsync (backups -> offsite) ==="
|
||||
dim " Source: $BACKUPS_ROOT"
|
||||
dim " Destination: $OFFSITE_ROOT"
|
||||
dim " Mode: new files and folders only"
|
||||
printf '\n'
|
||||
|
||||
if [ -z "$OFFSITE_ROOT" ]; then
|
||||
warn " paths.oneDriveBackups is not set in zconfig.json - nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -d "$BACKUPS_ROOT" ]; then
|
||||
warn " Source not found (skipped): $BACKUPS_ROOT"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "$OFFSITE_ROOT"
|
||||
|
||||
copied=0; skipped=0
|
||||
while IFS= read -r -d '' f; do
|
||||
rel="${f#"$BACKUPS_ROOT"/}"
|
||||
target="$OFFSITE_ROOT/$rel"
|
||||
if [ -e "$target" ]; then skipped=$((skipped + 1)); continue; fi
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp -p "$f" "$target"
|
||||
ok " Copied: $rel ($(z_size_mb "$f") MB)"
|
||||
copied=$((copied + 1))
|
||||
done < <(find "$BACKUPS_ROOT" -type f -print0 2>/dev/null)
|
||||
|
||||
printf '\n'; info " Done: $copied copied, $skipped already present"
|
||||
printf '\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- project mode: build + mirror a vite project's dist ----------------------
|
||||
z_require rsync
|
||||
for key in "${projects[@]}"; do
|
||||
zproj_require "$key"
|
||||
kind="$(zproj "$key" .kind)"
|
||||
if [ "$kind" != "vite" ]; then
|
||||
printf '\n'; warn "zsync project mode only supports vite kind (builds and mirrors dist/). '$key' is kind '$kind'."
|
||||
exit 1
|
||||
fi
|
||||
root="$(zproj "$key" .localRoot)"
|
||||
|
||||
if [ -z "$dest" ]; then
|
||||
printf '\n'; info "=== zsync ($(zproj "$key" .label)) ==="
|
||||
warn "No destination set."
|
||||
dim "Set ZSYNC_DEST or run: zsync $key --dest /path/to/folder"
|
||||
dim "This runs npm run build, then mirrors dist -> destination (rsync --delete)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\n'; info "=== zsync ($(zproj "$key" .label)) ==="
|
||||
info "Build + mirror dist -> $dest"
|
||||
printf '\n'
|
||||
|
||||
(cd "$root" && npm run build) || { err "npm run build failed"; exit 1; }
|
||||
[ -d "$root/dist" ] || { err "dist/ missing after build."; exit 1; }
|
||||
|
||||
mkdir -p "$dest"
|
||||
rsync -a --delete "$root/dist/" "$dest/" || { err "rsync failed"; exit 1; }
|
||||
|
||||
printf '\n'; ok "Sync complete."
|
||||
done
|
||||
Loading…
Reference in New Issue
Block a user