#!/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 pipefailPROG="$(basename "$0")"log() { echo "$@" >&2; }usage() { cat >&2 <<EOFUsage: $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 overstdin. Values are never echoed, never placed on a command line, and neverwritten 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 ;; esacdone[ -n "$DESTINATION" ] || { log "error: --destination is required"; usage; }if [ ${#NAMES[@]} -eq 0 ]; then log "error: at least one secret NAME is required" usageficase "$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."logfor 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=""doneloglog "Done. ${#NAMES[@]} secret(s) written to $DEST_LABEL."