_scripts/vercel-vars.sh#!/usr/bin/env bashset -euo pipefail# Sync env vars between .env.production and Vercel.## Direction:# push (default) — upsert vars FROM .env.production TO Vercel (encrypted),# targeting production AND preview by default.# Uses the Vercel REST API, so it needs an API token# (prompted for once, then saved to .vercel/vars-token).# pull — download your OWN production vars FROM Vercel TO# .env.production. Vercel-injected system vars (VERCEL_*,# TURBO_*, etc.) are excluded. Uses the Vercel CLI, so it# needs NO token — just a linked, logged-in project.# The existing .env.production is backed up to# .env.production-{timestamp} first.## Usage:# npm run vars # push to production + preview# npm run vars:push # push to production + preview# npm run vars:pull # pull production vars into .env.production# ./_scripts/vercel-vars.sh push "production" # override push targets# ./_scripts/vercel-vars.sh pull# --- parse direction (first positional arg), default to pushDIRECTION="push"case "${1:-}" inpush|pull) DIRECTION="$1"; shift ;;esacTARGETS_CSV="${1:-production,preview}" # development,preview,production (comma-separated, push only)ENV_FILE=".env.production"VERCEL_BIN="${VERCEL_BIN:-vercel}"ENV_TYPE="encrypted" # store pushed values as encrypted secrets# --- sanity checksif ! command -v "$VERCEL_BIN" >/dev/null 2>&1; thenecho "Error: Vercel CLI not found. Install with: npm i -g vercel" >&2exit 1fiif ! command -v jq >/dev/null 2>&1; thenecho "Error: 'jq' is required. Install with: brew install jq" >&2exit 1fiif [ "$DIRECTION" = "push" ] && [ ! -f "$ENV_FILE" ]; thenecho "Error: environment file not found: $ENV_FILE" >&2exit 1fi# --- ensure the project is linked. Git-connected projects store the link in# .vercel/repo.json (a projects[] array, monorepo-aware).if [ ! -f ".vercel/repo.json" ]; thenecho "Project not linked. Running 'vercel link' to select scope and project...""$VERCEL_BIN" linkfiif [ ! -f ".vercel/repo.json" ]; thenecho "Error: '.vercel/repo.json' not found after linking attempt." >&2exit 1fi# ============================================================================# PULL — download your own production vars into .env.production (no token)# ============================================================================if [ "$DIRECTION" = "pull" ]; then# --- back up the existing .env.production before overwriting itif [ -f "$ENV_FILE" ]; thenTIMESTAMP="$(date +%Y%m%d-%H%M%S)"BACKUP_FILE="${ENV_FILE}-${TIMESTAMP}"cp "$ENV_FILE" "$BACKUP_FILE"echo "Backed up existing $ENV_FILE to $BACKUP_FILE"fiecho "Pulling production variables from Vercel into $ENV_FILE"# --- allowlist: the project's OWN production var keys. `env ls` never lists# Vercel-injected system vars, so those are naturally excluded.allowed_keys="$("$VERCEL_BIN" env ls production --format json 2>/dev/null | jq -r '.envs[]| select(.type != "system")| select(.target != null and (.target | index("production")))| .key' | sort -u)"if [ -z "$allowed_keys" ]; thenecho "Error: no production variables found for the linked project." >&2exit 1fi# --- pull decrypted values via the CLI into a temp filetmp_pull="$(mktemp)""$VERCEL_BIN" env pull "$tmp_pull" --environment=production --yes# --- write only the project's own vars into .env.production: > "$ENV_FILE"count=0while IFS= read -r line || [ -n "$line" ]; docase "$line" in''|\#*) continue ;; # skip blanks + comments (e.g. Vercel header)esackey="${line%%=*}"if grep -qxF "$key" <<< "$allowed_keys"; thenprintf '%s\n' "$line" >> "$ENV_FILE"count=$((count + 1))fidone < "$tmp_pull"rm -f "$tmp_pull"echo "Wrote $count production variable(s) to $ENV_FILE"echo "Done."exit 0fi# ============================================================================# PUSH — upsert vars from .env.production to Vercel (REST API + token)# ============================================================================# --- read project + org IDs from repo.json (this directory, else first project)PROJECT_ID="$(jq -r '(.projects[] | select(.directory==".") | .id) // .projects[0].id // empty' .vercel/repo.json)"ORG_ID="$(jq -r '(.projects[] | select(.directory==".") | .orgId) // .projects[0].orgId // empty' .vercel/repo.json)"if [ -z "${PROJECT_ID:-}" ]; thenecho "Error: could not determine projectId from .vercel/repo.json" >&2exit 1fi# --- resolve a Vercel API token (needed for the REST upsert)TOKEN_FILE=".vercel/vars-token"STORED_TOKEN=""[ -f "$TOKEN_FILE" ] && STORED_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"if [ -n "$STORED_TOKEN" ]; thenVERCEL_TOKEN="$STORED_TOKEN"elif [ -n "${VERCEL_TOKEN:-}" ]; then: # use from environmentelseprintf "Enter your Vercel token (create one at https://vercel.com/account/tokens): "read -r VERCEL_TOKENif [ -z "$VERCEL_TOKEN" ]; thenecho "Error: Vercel token is required for push." >&2exit 1fifi# --- Build API base with team scopeAPI_BASE="https://api.vercel.com"TEAM_QS=""if [[ -n "${ORG_ID:-}" && "$ORG_ID" == team_* ]]; thenTEAM_QS="&teamId=${ORG_ID}"fi# --- Preflight: validate token before saving itPREFLIGHT_URL="${API_BASE}/v10/projects/${PROJECT_ID}?${TEAM_QS#&}"pre_code="$(curl -sS -o /tmp/vercel_pre.$$ -w '%{http_code}' \-H "Authorization: Bearer $VERCEL_TOKEN" \"$PREFLIGHT_URL")"if [ "$pre_code" = "401" ]; thenecho "Unauthorized: Your VERCEL_TOKEN is invalid or expired. It will NOT be saved." >&2cat /tmp/vercel_pre.$$ >&2 || truerm -f /tmp/vercel_pre.$$exit 1elif [ "$pre_code" = "403" ]; thenecho "Forbidden: Your token lacks access to this project/team. It will NOT be saved." >&2cat /tmp/vercel_pre.$$ >&2 || truerm -f /tmp/vercel_pre.$$exit 1elif ! [[ "$pre_code" =~ ^2 ]]; thenecho "Preflight failed (HTTP $pre_code). Response:" >&2cat /tmp/vercel_pre.$$ >&2 || truerm -f /tmp/vercel_pre.$$exit 1firm -f /tmp/vercel_pre.$$# --- If token is valid, save it to .vercel/vars-token (for future use).# .vercel/ is gitignored, so the token is never committed.if [ -z "$STORED_TOKEN" ]; then( umask 077; printf '%s\n' "$VERCEL_TOKEN" > "$TOKEN_FILE" )echo "Token validated and saved to $TOKEN_FILE"fi# --- prepare targets JSONIFS=',' read -r -a TARGETS <<< "$TARGETS_CSV"TARGETS_JSON="$(printf '%s\n' "${TARGETS[@]}" | awk 'BEGIN{printf "["; first=1} {if(!first)printf ","; printf "\""$0"\""; first=0} END{printf "]"}')"API_URL="${API_BASE}/v10/projects/${PROJECT_ID}/env?upsert=true${TEAM_QS}"echo "Upserting encrypted variables from $ENV_FILE to targets: ${TARGETS[*]} (projectId: $PROJECT_ID${ORG_ID:+, orgId: $ORG_ID})"# --- helper: strip surrounding quotesstrip_quotes () {local s="$1"if [[ "$s" =~ ^\".*\"$ ]]; then echo "${s:1:${#s}-2}"elif [[ "$s" =~ ^\'.*\'$ ]]; then echo "${s:1:${#s}-2}"else echo "$s"; fi}# --- loop through .env.production and upsert via APIwhile IFS= read -r raw || [ -n "$raw" ]; doline="$raw"line="${line#"${line%%[![:space:]]*}"}"line="${line%"${line##*[![:space:]]}"}"[[ -z "$line" || "${line:0:1}" == "#" ]] && continue[[ "$line" =~ ^export[[:space:]]+ ]] && line="${line#export }"[[ "$line" != *"="* ]] && { echo "Skipping invalid line: $line" >&2; continue; }key="${line%%=*}"val="${line#*=}"key="${key#"${key%%[![:space:]]*}"}"; key="${key%"${key##*[![:space:]]}"}"val="${val#"${val%%[![:space:]]*}"}"; val="${val%"${val##*[![:space:]]}"}"[[ -z "$key" ]] && continueval="$(strip_quotes "$val")"[[ -z "$val" ]] && { echo "Skipping $key (empty value)"; continue; }payload=$(cat <<JSON{"key": "$key","value": "$val","type": "$ENV_TYPE","target": $TARGETS_JSON}JSON)http_code="$(curl -sS -o /tmp/vercel_env_resp.$$ -w '%{http_code}' \-H "Authorization: Bearer $VERCEL_TOKEN" \-H "Content-Type: application/json" \-X POST "$API_URL" \-d "$payload")"if [ "$http_code" = "401" ]; thenecho "Unauthorized while upserting $key: token invalid or expired." >&2cat /tmp/vercel_env_resp.$$ >&2 || truerm -f /tmp/vercel_env_resp.$$exit 1elif [ "$http_code" = "403" ]; thenecho "Forbidden while upserting $key: token lacks access to project/team." >&2cat /tmp/vercel_env_resp.$$ >&2 || truerm -f /tmp/vercel_env_resp.$$exit 1elif ! [[ "$http_code" =~ ^2 ]]; thenecho "Failed to upsert $key (HTTP $http_code)"; cat /tmp/vercel_env_resp.$$ >&2elseecho "$key upserted"firm -f /tmp/vercel_env_resp.$$done < "$ENV_FILE"echo "Done."