raw
sh
#!/usr/bin/env bash
#
# Collect a secret from a human into a destination without leaking it.
#
# Prompts for each named secret one at a time with hidden input, then writes
# the value straight to the chosen destination over stdin. The value is never
# echoed, never placed on a command line (so it stays out of `ps` and shell
# history), and never written to disk beyond the intended destination. After
# each write the script prints a length-only confirmation (`set NAME (N chars)`)
# so you can sanity-check a paste without revealing it.
#
# Usage:
#   collect-secrets.sh --destination gh --repo OWNER/REPO NAME [NAME...]
#   collect-secrets.sh --destination wrangler [--env ENV] NAME [NAME...]
#   collect-secrets.sh --destination dev-vars [--file PATH] NAME [NAME...]
#
# Destinations:
#   gh         GitHub repo secret via `gh secret set NAME --repo OWNER/REPO`
#              (value piped over stdin). Requires --repo.
#   wrangler   Cloudflare Worker secret via `wrangler secret put NAME`
#              (value piped over stdin). Optional --env targets a named
#              environment.
#   dev-vars   Append/replace NAME=value in a gitignored dotfile (default
#              .dev.vars). The file is the intended on-disk destination; it is
#              written with mode 600 and the script warns if it is not
#              gitignored.
#
# Every destination is idempotent: re-running overwrites the secret (gh /
# wrangler) or replaces the key in place (dev-vars), so the script is safe to
# re-run.
#
# Reading values:
#   Each value is read hidden from stdin, one at a time. In a terminal that is
#   your keyboard; run non-interactively, an agent can forward a paste by
#   piping values in. The destination reads its own stdin from the internal
#   pipe, so the two never compete. Empty input re-prompts; end-of-input aborts.

set -euo pipefail

PROG="$(basename "$0")"

log() { echo "$@" >&2; }

usage() {
  cat >&2 <<EOF
Usage:
  $PROG --destination gh --repo OWNER/REPO NAME [NAME...]
  $PROG --destination wrangler [--env ENV] NAME [NAME...]
  $PROG --destination dev-vars [--file PATH] NAME [NAME...]

Prompts for each NAME with hidden input and writes it to the destination over
stdin. Values are never echoed, never placed on a command line, and never
written to disk beyond the intended destination.
EOF
  exit 2
}

require_command() {
  if ! command -v "$1" >/dev/null 2>&1; then
    log "error: $1 not found in PATH"
    exit 1
  fi
}

# Read one secret value, hidden, re-prompting until non-empty. Reads from stdin
# (fd 0); the destination reads its own stdin from the `printf | dest` pipe, so
# the two never compete. The value is returned via the global REPLY_SECRET to
# keep it off argv.
REPLY_SECRET=""
read_secret() {
  local name="$1" val="" rc=0
  while [ -z "$val" ]; do
    printf 'Paste %s: ' "$name" >&2
    # Read hidden from stdin (fd 0). The destination reads its own stdin from
    # the `printf | dest` pipe, so the two never compete; this also lets an
    # agent forward a paste by piping values in.
    IFS= read -rs val && rc=0 || rc=$?
    echo >&2
    # read returns non-zero at end of input. A non-empty val on EOF (a paste
    # with no trailing newline) is still valid; an empty val on EOF means the
    # input is exhausted, so stop instead of looping forever.
    if [ -n "$val" ]; then
      break
    elif [ "$rc" -ne 0 ]; then
      log "error: no value for $name (end of input)"
      exit 1
    else
      log "  (empty, paste again)"
    fi
  done
  REPLY_SECRET="$val"
}

# Destinations. Each reads the value from stdin and must not place it on argv.
dest_gh() {
  local name="$1"
  gh secret set "$name" --repo "$REPO"
}

dest_wrangler() {
  local name="$1"
  if [ -n "$WRANGLER_ENV" ]; then
    wrangler secret put "$name" --env "$WRANGLER_ENV"
  else
    wrangler secret put "$name"
  fi
}

dest_dev_vars() {
  local name="$1" line tmp
  # The value arrives on stdin; capture it, then rewrite the file with the key
  # replaced in place. The value never reaches argv.
  local val
  val="$(cat)"
  umask 077
  tmp="$(mktemp "${DEV_VARS_FILE}.XXXXXX")"
  if [ -f "$DEV_VARS_FILE" ]; then
    # Drop any existing assignment for this key, keep everything else.
    while IFS= read -r line || [ -n "$line" ]; do
      case "$line" in
        "$name"=*) ;;
        *) printf '%s\n' "$line" >>"$tmp" ;;
      esac
    done <"$DEV_VARS_FILE"
  fi
  printf '%s=%s\n' "$name" "$val" >>"$tmp"
  chmod 600 "$tmp"
  mv "$tmp" "$DEV_VARS_FILE"
}

warn_if_tracked() {
  # dev-vars only: nudge the user if the destination file would be committed.
  local f="$DEV_VARS_FILE"
  if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    if ! git check-ignore -q "$f" 2>/dev/null; then
      log "warning: $f is not gitignored; add it to .gitignore so the"
      log "         secret is never committed."
    fi
  fi
}

# --- Parse args ---------------------------------------------------------------

DESTINATION=""
REPO=""
WRANGLER_ENV=""
DEV_VARS_FILE=".dev.vars"
NAMES=()

while [ $# -gt 0 ]; do
  case "$1" in
    --destination) DESTINATION="${2:-}"; shift 2 ;;
    --repo)  REPO="${2:-}"; shift 2 ;;
    --env)   WRANGLER_ENV="${2:-}"; shift 2 ;;
    --file)  DEV_VARS_FILE="${2:-}"; shift 2 ;;
    -h|--help) usage ;;
    --*)     log "error: unknown flag: $1"; usage ;;
    *)       NAMES+=("$1"); shift ;;
  esac
done

[ -n "$DESTINATION" ] || { log "error: --destination is required"; usage; }
if [ ${#NAMES[@]} -eq 0 ]; then
  log "error: at least one secret NAME is required"
  usage
fi

case "$DESTINATION" in
  gh)
    require_command gh
    if [ -z "$REPO" ]; then
      log "error: --destination gh requires --repo OWNER/REPO"
      usage
    fi
    DEST_FN=dest_gh
    DEST_LABEL="GitHub repo secrets on $REPO"
    ;;
  wrangler)
    require_command wrangler
    DEST_FN=dest_wrangler
    DEST_LABEL="Worker secrets${WRANGLER_ENV:+ (env: $WRANGLER_ENV)}"
    ;;
  dev-vars)
    DEST_FN=dest_dev_vars
    DEST_LABEL="$DEV_VARS_FILE"
    warn_if_tracked
    ;;
  *)
    log "error: unknown destination: $DESTINATION"
    log "       (expected gh, wrangler, or dev-vars)"
    usage
    ;;
esac

# --- Prompt and write ---------------------------------------------------------

log "Writing ${#NAMES[@]} secret(s) to $DEST_LABEL"
log "Each value is read hidden and piped straight to the destination: never"
log "echoed, never on a command line, never written to disk beyond it."
log

for name in "${NAMES[@]}"; do
  read_secret "$name"
  # Pipe the value to the destination over stdin. printf keeps it off argv. The
  # value lives only in REPLY_SECRET and the pipe; clear it right after.
  if printf '%s' "$REPLY_SECRET" | "$DEST_FN" "$name"; then
    log "  set $name (${#REPLY_SECRET} chars)"
  else
    log "  ✗ failed to set $name"
    REPLY_SECRET=""
    exit 1
  fi
  REPLY_SECRET=""
done

log
log "Done. ${#NAMES[@]} secret(s) written to $DEST_LABEL."