fix(install.sh): guard empty-array expansion for bash 3.2
curl | bash on macOS runs the script under /bin/bash, which is
still 3.2 (Apple has stayed there for licensing reasons since
GPLv3). In 3.2 a bare ${CURL_AUTH[@]} on an empty array under
set -u throws "CURL_AUTH[@]: unbound variable" and aborts \u2014 so
every public-repo install on macOS died on line 129 before the
tarball could even be fetched.
Bash 4+ handles empty-array expansion fine, so this only ever
bit macOS users. The standard 3.2-compatible workaround is
${CURL_AUTH[@]+"${CURL_AUTH[@]}"}, which expands to nothing
when the array is empty and to its contents otherwise. Applied
at every call site that expanded the array unconditionally.
Added a comment at the declaration explaining why the guard is
there so the next person doesn't take it back out.
Verified against /bin/bash 3.2 on macOS: the unbound-variable
error is gone; with and without GITHUB_TOKEN the script now
proceeds to the download step.- commit
- 9d0b1d08827c592a6c04a4053c2ff965872d36fa
- author
- patriceckhart · · 2026-04-20 15:24 UTC
- parent
- ad60f82
- signature
- unsigned
- refs
- tag: v0.0.71
1 file · +13 -5
M install.sh +13 -5
| @@ -43,8 +43,16 @@ command -v curl >/dev/null 2>&1 || die "curl is required" | |||
| 43 | 43 | command -v tar >/dev/null 2>&1 || die "tar is required" | |
| 44 | 44 | ||
| 45 | 45 | # CURL_AUTH is prepended to every curl invocation so private-repo | |
| 46 | - | # downloads work while $GITHUB_TOKEN is set. Empty string when the repo | |
| 47 | - | # becomes public. | |
| 46 | + | # downloads work while $GITHUB_TOKEN is set. Empty array when the repo | |
| 47 | + | # is public. | |
| 48 | + | # | |
| 49 | + | # Note the "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" pattern at every call | |
| 50 | + | # site: bash 3.2 (the default /bin/bash on macOS) treats an | |
| 51 | + | # unquoted empty-array expansion as an unbound variable under | |
| 52 | + | # `set -u` and aborts with "CURL_AUTH[@]: unbound variable". The | |
| 53 | + | # guard expands to nothing when the array is empty and to the | |
| 54 | + | # array's contents when it isn't. Bash 4+ doesn't need this, but | |
| 55 | + | # the installer's primary audience is `curl | bash` on macOS. | |
| 48 | 56 | CURL_AUTH=() | |
| 49 | 57 | if [ -n "${GITHUB_TOKEN:-}" ]; then | |
| 50 | 58 | CURL_AUTH=(-H "Authorization: Bearer $GITHUB_TOKEN") | |
| @@ -76,7 +84,7 @@ if [ "$VERSION" = "latest" ]; then | |||
| 76 | 84 | # Private-repo friendly: hit the api, grab tag_name. Falls back to | |
| 77 | 85 | # following the /releases/latest redirect on public repos. | |
| 78 | 86 | if [ ${#CURL_AUTH[@]} -gt 0 ]; then | |
| 79 | - | VERSION=$(curl -fsSL "${CURL_AUTH[@]}" \ | |
| 87 | + | VERSION=$(curl -fsSL "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" \ | |
| 80 | 88 | "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" \ | |
| 81 | 89 | | sed -nE 's/.*"tag_name": *"([^"]+)".*/\1/p' | head -n1) | |
| 82 | 90 | else | |
| @@ -126,11 +134,11 @@ TMP=$(mktemp -d) | |||
| 126 | 134 | trap 'rm -rf "$TMP"' EXIT | |
| 127 | 135 | ||
| 128 | 136 | msg "downloading ${ARCHIVE}" | |
| 129 | - | curl -fsSL "${CURL_AUTH[@]}" -o "$TMP/$ARCHIVE" "$ARCHIVE_URL" \ | |
| 137 | + | curl -fsSL "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" -o "$TMP/$ARCHIVE" "$ARCHIVE_URL" \ | |
| 130 | 138 | || die "download failed: $ARCHIVE_URL (set GITHUB_TOKEN if the repo is private)" | |
| 131 | 139 | ||
| 132 | 140 | msg "verifying checksum" | |
| 133 | - | curl -fsSL "${CURL_AUTH[@]}" -o "$TMP/checksums.txt" "$CHECKSUMS_URL" \ | |
| 141 | + | curl -fsSL "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" -o "$TMP/checksums.txt" "$CHECKSUMS_URL" \ | |
| 134 | 142 | || die "download failed: $CHECKSUMS_URL" | |
| 135 | 143 | ||
| 136 | 144 | expected=$(grep " ${ARCHIVE}\$" "$TMP/checksums.txt" | awk '{print $1}' || true) | |
| @@ -43,8 +43,16 @@ command -v curl >/dev/null 2>&1 || die "curl is required" | |||||
| 43 | command -v tar >/dev/null 2>&1 || die "tar is required" | 43 | command -v tar >/dev/null 2>&1 || die "tar is required" | ||
| 44 | 44 | ||||
| 45 | # CURL_AUTH is prepended to every curl invocation so private-repo | 45 | # CURL_AUTH is prepended to every curl invocation so private-repo | ||
| 46 | - | # downloads work while $GITHUB_TOKEN is set. Empty string when the repo | 46 | + | # downloads work while $GITHUB_TOKEN is set. Empty array when the repo |
| 47 | - | # becomes public. | 47 | + | # is public. |
| 48 | + | # | |||
| 49 | + | # Note the "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" pattern at every call | |||
| 50 | + | # site: bash 3.2 (the default /bin/bash on macOS) treats an | |||
| 51 | + | # unquoted empty-array expansion as an unbound variable under | |||
| 52 | + | # `set -u` and aborts with "CURL_AUTH[@]: unbound variable". The | |||
| 53 | + | # guard expands to nothing when the array is empty and to the | |||
| 54 | + | # array's contents when it isn't. Bash 4+ doesn't need this, but | |||
| 55 | + | # the installer's primary audience is `curl | bash` on macOS. | |||
| 48 | CURL_AUTH=() | 56 | CURL_AUTH=() | ||
| 49 | if [ -n "${GITHUB_TOKEN:-}" ]; then | 57 | if [ -n "${GITHUB_TOKEN:-}" ]; then | ||
| 50 | CURL_AUTH=(-H "Authorization: Bearer $GITHUB_TOKEN") | 58 | CURL_AUTH=(-H "Authorization: Bearer $GITHUB_TOKEN") | ||
| @@ -76,7 +84,7 @@ if [ "$VERSION" = "latest" ]; then | |||||
| 76 | # Private-repo friendly: hit the api, grab tag_name. Falls back to | 84 | # Private-repo friendly: hit the api, grab tag_name. Falls back to | ||
| 77 | # following the /releases/latest redirect on public repos. | 85 | # following the /releases/latest redirect on public repos. | ||
| 78 | if [ ${#CURL_AUTH[@]} -gt 0 ]; then | 86 | if [ ${#CURL_AUTH[@]} -gt 0 ]; then | ||
| 79 | - | VERSION=$(curl -fsSL "${CURL_AUTH[@]}" \ | 87 | + | VERSION=$(curl -fsSL "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" \ |
| 80 | "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" \ | 88 | "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" \ | ||
| 81 | | sed -nE 's/.*"tag_name": *"([^"]+)".*/\1/p' | head -n1) | 89 | | sed -nE 's/.*"tag_name": *"([^"]+)".*/\1/p' | head -n1) | ||
| 82 | else | 90 | else | ||
| @@ -126,11 +134,11 @@ TMP=$(mktemp -d) | |||||
| 126 | trap 'rm -rf "$TMP"' EXIT | 134 | trap 'rm -rf "$TMP"' EXIT | ||
| 127 | 135 | ||||
| 128 | msg "downloading ${ARCHIVE}" | 136 | msg "downloading ${ARCHIVE}" | ||
| 129 | - | curl -fsSL "${CURL_AUTH[@]}" -o "$TMP/$ARCHIVE" "$ARCHIVE_URL" \ | 137 | + | curl -fsSL "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" -o "$TMP/$ARCHIVE" "$ARCHIVE_URL" \ |
| 130 | || die "download failed: $ARCHIVE_URL (set GITHUB_TOKEN if the repo is private)" | 138 | || die "download failed: $ARCHIVE_URL (set GITHUB_TOKEN if the repo is private)" | ||
| 131 | 139 | ||||
| 132 | msg "verifying checksum" | 140 | msg "verifying checksum" | ||
| 133 | - | curl -fsSL "${CURL_AUTH[@]}" -o "$TMP/checksums.txt" "$CHECKSUMS_URL" \ | 141 | + | curl -fsSL "${CURL_AUTH[@]+"${CURL_AUTH[@]}"}" -o "$TMP/checksums.txt" "$CHECKSUMS_URL" \ |
| 134 | || die "download failed: $CHECKSUMS_URL" | 142 | || die "download failed: $CHECKSUMS_URL" | ||
| 135 | 143 | ||||
| 136 | expected=$(grep " ${ARCHIVE}\$" "$TMP/checksums.txt" | awk '{print $1}' || true) | 144 | expected=$(grep " ${ARCHIVE}\$" "$TMP/checksums.txt" | awk '{print $1}' || true) | ||