fix(bash): make the port bash-3.2 / BSD-clean so it runs on stock macOS (#8)

The README advertises macOS support, but the port used constructs that
fail on the userland macOS actually ships:

- `mapfile` (bash 4+) in 10 spots — macOS ships bash 3.2 as /usr/bin/bash,
  so a mac user following the README hit "mapfile: command not found" and
  silently got empty project lists. Replace each with a portable
  `while IFS= read -r` loop (identical arrays; set -u safe on empty input).
- `_z_commafy` used the GNU-only `sed :a;...;ta` label/branch idiom, which
  errors on BSD/macOS sed (the token footer's thousands separators).
  Reimplement with awk (already a dependency).
- README: correct the macOS line — bash 4+ does NOT ship with macOS; the
  stock 3.2 now works, and only jq needs brew.

Also two correctness nits found in the same review:
- zkill/zrestart `--kill-all` was parsed but silently ignored; now it
  prints a "not implemented in the bash port" notice instead of no-op.
- zrepair's smoke-test line said "https://$domain" but probes
  http://$HOST with a Host header; label now matches what it does.

Verified on WSL (bash 5): read-loops produce the same 11 project / 7
domain keys as mapfile; awk commafy matches across 0..1,234,567; empty
producer yields a 0-length array; footer renders. 3.2-compat is by static
analysis — no bash-4-only constructs remain.
This commit is contained in:
kellymichels 2026-07-22 18:41:11 -05:00 committed by GitHub
parent 3fcc3546b7
commit 4ebde3ebcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 30 additions and 15 deletions

View File

@ -11,12 +11,12 @@ same behavior — no PowerShell required.
## Requirements ## Requirements
- bash 4+, `jq`, `curl`, `zip`, OpenSSH (`ssh`/`scp`) - bash 3.2+ (the stock macOS bash works), `jq`, `curl`, `zip`, OpenSSH (`ssh`/`scp`)
- `lsof` (for `zkill`/`zrestart`), `rsync` (for `zsync <project>` mirror mode) - `lsof` (for `zkill`/`zrestart`), `rsync` (for `zsync <project>` mirror mode)
- Docker + docker compose on the remote host for the deploy scripts - Docker + docker compose on the remote host for the deploy scripts
Debian/Ubuntu: `sudo apt install jq curl zip lsof rsync` Debian/Ubuntu: `sudo apt install jq curl zip lsof rsync`
macOS: `brew install jq` (the rest ships with the OS) macOS: `brew install jq` (bash, curl, zip, ssh, lsof and rsync already ship with macOS)
## Install ## Install

View File

@ -32,7 +32,7 @@ done
BACKUP_ROOT="$(z_path "$(zq '.paths.backupsLocal')")" BACKUP_ROOT="$(z_path "$(zq '.paths.backupsLocal')")"
include_scripts=0 include_scripts=0
if [ "${#projects[@]}" -eq 0 ]; then if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(zproj_keys) projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys)
include_scripts=1 include_scripts=1
else else
filtered=() filtered=()
@ -92,7 +92,7 @@ backup_project() { # <key>
warn " [2/3] Archiving source..." warn " [2/3] Archiving source..."
local excl=() local excl=()
mapfile -t excl < <(z_archive_excludes "$key" 1) excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(z_archive_excludes "$key" 1)
if ! z_archive "$root" "$zip_path" 0 "$extra" "${excl[@]}"; then if ! z_archive "$root" "$zip_path" 0 "$extra" "${excl[@]}"; then
rm -rf "$dump_dir"; return 1 rm -rf "$dump_dir"; return 1
fi fi

View File

@ -22,7 +22,8 @@ z_require ssh scp
projects=("$@") projects=("$@")
if [ "${#projects[@]}" -eq 0 ]; then if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.remote.path != null) | .key' "$ZCONFIG") projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done \
< <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.remote.path != null) | .key' "$ZCONFIG")
fi fi
EC2_BACKUP_ROOT="$(z_path "$(zq '.paths.backupsEc2')")" EC2_BACKUP_ROOT="$(z_path "$(zq '.paths.backupsEc2')")"

View File

@ -46,8 +46,10 @@ mkdir -p "$TEMP_ROOT"
# 'all' -> every project, edge kinds first (the proxy must route before apps ship). # 'all' -> every project, edge kinds first (the proxy must route before apps ship).
if printf '%s\n' "${projects[@]}" | grep -qx 'all'; then 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") edge_keys=(); while IFS= read -r _zl || [ -n "$_zl" ]; do edge_keys+=("$_zl"); done \
mapfile -t rest_keys < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind != "edge") | .key' "$ZCONFIG") < <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind == "edge") | .key' "$ZCONFIG")
rest_keys=(); while IFS= read -r _zl || [ -n "$_zl" ]; do rest_keys+=("$_zl"); done \
< <(jq -r '.projects | to_entries[] | select((.key | startswith("_") | not) and .value.kind != "edge") | .key' "$ZCONFIG")
projects=("${edge_keys[@]+"${edge_keys[@]}"}" "${rest_keys[@]+"${rest_keys[@]}"}") projects=("${edge_keys[@]+"${edge_keys[@]}"}" "${rest_keys[@]+"${rest_keys[@]}"}")
info "Deploying all projects: $(printf '%s ' "${projects[@]}")" info "Deploying all projects: $(printf '%s ' "${projects[@]}")"
fi fi
@ -203,7 +205,7 @@ deploy_python() { # <key>
printf '\n'; info "--- [1] Zipping $label ---" printf '\n'; info "--- [1] Zipping $label ---"
find "$root" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null find "$root" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
mapfile -t excl < <(z_archive_excludes "$key" 0) excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(z_archive_excludes "$key" 0)
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1 z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; } preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
@ -289,7 +291,7 @@ deploy_vite() { # <key>
pre_bn="$(jq -r '.buildNumber // 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)" [ -n "$pre_pv" ] && dim " Pre-zip build label: v${pre_pv}.${pre_bn} (server-side build will bump +1)"
fi fi
mapfile -t excl < <(z_archive_excludes "$key" 0) excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(z_archive_excludes "$key" 0)
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1 z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; } preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }
@ -347,7 +349,7 @@ deploy_next() { # <key>
fi fi
printf '\n'; info "--- [1] Zipping project files ---" printf '\n'; info "--- [1] Zipping project files ---"
mapfile -t excl < <(z_archive_excludes "$key" 0) excl=(); while IFS= read -r _zl || [ -n "$_zl" ]; do excl+=("$_zl"); done < <(z_archive_excludes "$key" 0)
z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1 z_archive "$root" "$zip_local" 0 "-" "${excl[@]}" || return 1
preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; } preflight_cleanup "$REMOTE_HOME/$zip_name" || { rm -f "$zip_local"; return 1; }

View File

@ -28,7 +28,7 @@ done
HOST="${host_override:-$(zec2_ip)}" HOST="${host_override:-$(zec2_ip)}"
if [ "${#projects[@]}" -eq 0 ]; then if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(zproj_keys_with_domain) projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys_with_domain)
if [ "${#projects[@]}" -eq 0 ]; then if [ "${#projects[@]}" -eq 0 ]; then
warn "No projects with a 'domain' configured in zconfig.json." warn "No projects with a 'domain' configured in zconfig.json."
exit 1 exit 1

View File

@ -32,7 +32,7 @@ HOST="${host_override:-$(zec2_ip)}"
EDGE_KEY="$(zedge_key)" EDGE_KEY="$(zedge_key)"
if [ "${#projects[@]}" -eq 0 ]; then if [ "${#projects[@]}" -eq 0 ]; then
mapfile -t projects < <(zproj_keys_with_domain) projects=(); while IFS= read -r _zl || [ -n "$_zl" ]; do projects+=("$_zl"); done < <(zproj_keys_with_domain)
if [ "${#projects[@]}" -eq 0 ]; then if [ "${#projects[@]}" -eq 0 ]; then
warn "No projects with a 'domain' configured in zconfig.json." warn "No projects with a 'domain' configured in zconfig.json."
exit 1 exit 1

View File

@ -311,8 +311,16 @@ z_track_stop() {
Z_TRACK_FILE="" Z_TRACK_FILE=""
} }
# Portable thousands separators (avoids locale-dependent printf %'d). # Portable thousands separators for a non-negative integer string (avoids both
_z_commafy() { printf '%s' "$1" | sed -E ':a;s/([0-9])([0-9]{3})($|[^0-9])/\1,\2\3/;ta'; } # locale-dependent printf %'d and the GNU-only `sed :a;...;ta` idiom, which
# errors on BSD/macOS sed).
_z_commafy() {
awk -v n="$1" 'BEGIN{
s = n ""; out = ""
while (length(s) > 3) { out = "," substr(s, length(s) - 2) out; s = substr(s, 1, length(s) - 3) }
printf "%s%s", s, out
}'
}
# Append a run to the ztokens JSONL store. Data dir precedence: $ZTOKENS_DATA, # Append a run to the ztokens JSONL store. Data dir precedence: $ZTOKENS_DATA,
# then config "ztokens.dataDir", then the sibling ../../ztokens/data if present # then config "ztokens.dataDir", then the sibling ../../ztokens/data if present

View File

@ -31,6 +31,10 @@ if [ "${#projects[@]}" -eq 0 ]; then
exit 1 exit 1
fi fi
# --kill-all (kill stray project processes beyond the dev-port listener) isn't
# ported yet; announce it rather than silently accepting the flag and doing nothing.
[ "$kill_all" -eq 1 ] && warn "Note: --kill-all isn't implemented in the bash port yet - killing dev-port listeners only."
for key in "${projects[@]}"; do for key in "${projects[@]}"; do
zproj_require "$key" zproj_require "$key"
label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key" label="$(zproj "$key" .label)"; [ -n "$label" ] || label="$key"

View File

@ -60,7 +60,7 @@ for key in "$@"; do
"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 "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 if [ -n "$domain" ]; then
warn " [3/3] Smoke test for https://$domain..." warn " [3/3] Smoke test for $domain (via http://$HOST)..."
code="$(http_code "http://$HOST/" "$domain")" code="$(http_code "http://$HOST/" "$domain")"
if [ "$code" = "000" ]; then if [ "$code" = "000" ]; then
err " FAIL - smoke test failed: no HTTP response" err " FAIL - smoke test failed: no HTTP response"