#!/usr/bin/env bash set -euo pipefail # SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 # secrets — encrypted env file sync between machines # Uses age key-file encryption + a private git repo. # F5: explicit HOME check. `set -u` would already error if HOME were unset, # but the message would be cryptic ("HOME: unbound variable" pointing at # the SECRETS_DIR default-init line). Check up front with a directed message # so cron/sudo/CI users know what to fix. : "${HOME:?HOME is not set; secrets needs a home directory to find or create the store}" # Capture the user-provided SECRETS_DIR (if any) before defaulting. # resolve_store() uses this to honor SECRETS_DIR as the legacy escape hatch # while letting .secrets-store files take precedence per project. _USER_SECRETS_DIR="${SECRETS_DIR:-}" SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}" KEY_FILE="$SECRETS_DIR/key.txt" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Set by resolve_store(). Reports which rule chose SECRETS_DIR. STORE_SOURCE="default" # Path to the .secrets-store file that won resolution, if any. _LAST_FOUND_AT="" # --store flag value, captured by the pre-pass. STORE_OVERRIDE="" # ─── Helpers ─────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } info() { echo "==> $*"; } check_cmd() { command -v "$1" >/dev/null 2>&1 || die "'$1' is not installed. Run: brew install $1" } check_initialized() { if [ -d "$SECRETS_DIR/.git" ]; then return fi if [ "$STORE_SOURCE" != "default" ]; then die "Store not initialized: $SECRETS_DIR Resolved from: $STORE_SOURCE This path doesn't exist on this machine yet. If you're joining a teammate's existing store: git clone $SECRETS_DIR # then copy their key.txt to $SECRETS_DIR/key.txt If you want a fresh new store at this path: secrets --store $SECRETS_DIR init" fi die "Not initialized. Run: secrets init" } check_key() { if [ -f "$KEY_FILE" ]; then return fi if [ "$STORE_SOURCE" != "default" ]; then die "Key file not found at $KEY_FILE The store at $SECRETS_DIR exists but has no key.txt. Resolved from: $STORE_SOURCE If joining a teammate's store: copy their key.txt to $KEY_FILE. If this is your own new store: re-run init for this store path." fi die "Key file not found at $KEY_FILE. Run: secrets init" } get_pubkey() { age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE" } derive_project_name() { local explicit="${1:-}" if [ -n "$explicit" ]; then echo "$explicit" return fi # Try git remote basename local remote remote=$(git config --get remote.origin.url 2>/dev/null || true) if [ -n "$remote" ]; then basename "$remote" .git return fi # Fall back to current directory name basename "$PWD" } # ─── Store resolution ────────────────────────────────────────────────── # # Resolution order (highest precedence first): # 1. --store flag (captured by main pre-pass into STORE_OVERRIDE) # 2. .secrets-store file in cwd or any ancestor up to but NOT including $HOME # 3. SECRETS_DIR env var (legacy escape hatch) # 4. $HOME/.secrets default # # .secrets-store file format: first non-empty non-comment line is the store # spec. Spec is one of: # - absolute path (/Users/you/.secrets-work) # - ~/path (expanded to $HOME/path) # - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets) # Comments (#) and CRLF line endings are tolerated. NO shell expansion is # applied to the file content — `$VAR`, `$(...)`, and backticks are read as # literal characters to prevent code injection from a committed file. # Take a store spec (path or bare name) and return an absolute directory. _expand_store_path() { local path="$1" case "$path" in /*) echo "$path" ;; '~') echo "$HOME" ;; '~/'*) echo "$HOME/${path#\~/}" ;; .*|*/*) # Relative path or path containing /. Treat as path relative to cwd. echo "$path" ;; default) # Sugar: --store default → the canonical default store echo "$HOME/.secrets" ;; *) # Bare name like "work" → $HOME/.secrets-work echo "$HOME/.secrets-$path" ;; esac } # Read a .secrets-store file. Print the first non-empty non-comment line, # trimmed. Return 1 if no usable line is found. _parse_secrets_store_file() { local file="$1" local line while IFS= read -r line || [ -n "$line" ]; do # Strip CRLF line="${line%$'\r'}" # Trim leading whitespace line="${line#"${line%%[![:space:]]*}"}" # Trim trailing whitespace line="${line%"${line##*[![:space:]]}"}" [ -z "$line" ] && continue case "$line" in '#'*) continue ;; esac printf '%s\n' "$line" return 0 done < "$file" return 1 } # Walk up from cwd looking for .secrets-store. Bounded by $HOME — never # walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp), # the walk does not run. Symlinks are resolved with `cd -P`. # On success: prints expanded store dir, sets _LAST_FOUND_AT, returns 0. _find_secrets_store_file() { local dir dir=$(pwd -P 2>/dev/null) || dir="$PWD" local home_resolved home_resolved=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_resolved="$HOME" while [ -n "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "$home_resolved" ]; do # Bound: only walk while we're strictly below $HOME. case "$dir" in "$home_resolved"/*) ;; *) return 1 ;; esac # F2: never follow a symlinked .secrets-store. A committed symlink # could point at any user-readable file (~/.aws/credentials, /etc/passwd) # and trick the resolver into reading attacker-chosen content. if [ -L "$dir/.secrets-store" ]; then : elif [ -f "$dir/.secrets-store" ]; then local content if content=$(_parse_secrets_store_file "$dir/.secrets-store"); then local expanded expanded=$(_expand_store_path "$content") _LAST_FOUND_AT="$dir/.secrets-store" printf '%s\n' "$expanded" return 0 fi fi dir=$(dirname "$dir") done return 1 } # Resolve the active store directory and update SECRETS_DIR + KEY_FILE. # Sets STORE_SOURCE to one of: # "--store flag" | ".secrets-store file ()" | "SECRETS_DIR env var" | "default" resolve_store() { local resolved="" local source="" if [ -n "${STORE_OVERRIDE:-}" ]; then resolved=$(_expand_store_path "$STORE_OVERRIDE") source="--store flag" _LAST_FOUND_AT="" elif resolved=$(_find_secrets_store_file); then source=".secrets-store file ($_LAST_FOUND_AT)" elif [ -n "${_USER_SECRETS_DIR:-}" ]; then resolved="$_USER_SECRETS_DIR" source="SECRETS_DIR env var" _LAST_FOUND_AT="" else resolved="$HOME/.secrets" source="default" _LAST_FOUND_AT="" fi SECRETS_DIR="$resolved" KEY_FILE="$SECRETS_DIR/key.txt" STORE_SOURCE="$source" } # Echo "==> Store: ..." when SECRETS_DIR is not the canonical default. # Called from cmd_push/cmd_pull (and their workspace variants) after resolution. echo_store_if_non_default() { if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then info "Store: $SECRETS_DIR (from $STORE_SOURCE)" fi } # ─── End store resolution ────────────────────────────────────────────── # Collect secret files from a directory: # .env, .env.*, .dev.vars (excluding .envrc, .environment-*) # Sets the COLLECTED_FILES array. Returns 1 if no files found. collect_env_files() { local dir="$1" COLLECTED_FILES=() for f in "$dir"/.env "$dir"/.env.* "$dir"/.dev.vars; do [ -f "$f" ] || continue local basename_f basename_f=$(basename "$f") case "$basename_f" in .envrc|.environment*) continue ;; esac COLLECTED_FILES+=("$f") done [ ${#COLLECTED_FILES[@]} -gt 0 ] } # Read package.json workspaces and expand globs to actual directories. # Prints one workspace path per line (relative to the monorepo root). get_workspaces() { local root="$1" local pkg="$root/package.json" [ -f "$pkg" ] || die "No package.json found in $root" check_cmd jq local patterns patterns=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null) [ -n "$patterns" ] || die "No workspaces field in $pkg" # Expand each glob pattern relative to root local old_dir="$PWD" cd "$root" for pattern in $patterns; do # Use bash glob expansion for dir in $pattern; do [ -d "$dir" ] && echo "$dir" done done cd "$old_dir" } install_hook() { local hook_src="$SCRIPT_DIR/hooks/pre-commit" local hook_dst="$SECRETS_DIR/.git/hooks/pre-commit" if [ -f "$hook_src" ]; then cp "$hook_src" "$hook_dst" chmod +x "$hook_dst" else # Inline hook if template not found (e.g. secrets installed standalone) cat > "$hook_dst" << 'HOOKEOF' #!/usr/bin/env bash BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true) if [ -n "$BLOCKED" ]; then echo "ERROR: Plaintext secret files staged for commit:" echo "$BLOCKED" echo "Only .age (encrypted) files should be committed." exit 1 fi HOOKEOF chmod +x "$hook_dst" fi } # ─── Subcommands ─────────────────────────────────────────────────────── cmd_init() { check_cmd age check_cmd git resolve_store if [ -d "$SECRETS_DIR/.git" ]; then die "Already initialized at $SECRETS_DIR. Key file preserved." fi info "Initializing secrets repo at $SECRETS_DIR" mkdir -p "$SECRETS_DIR" git init "$SECRETS_DIR" >/dev/null # Generate age key pair info "Generating age key pair" age-keygen -o "$KEY_FILE" 2>&1 # Write .gitignore cat > "$SECRETS_DIR/.gitignore" << 'EOF' # Never commit the private key key.txt # Block plaintext secret files **/.env **/.env.* **/.dev.vars # Allow encrypted files !**/.env.age !**/.env.*.age !**/.dev.vars.age EOF # Install pre-commit hook mkdir -p "$SECRETS_DIR/.git/hooks" install_hook local pubkey pubkey=$(get_pubkey) info "Done! Your public key is:" echo " $pubkey" echo "" echo "Next steps:" echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin " echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" echo " 3. Run 'secrets push ' from a project directory" } # Encrypt env files from a source dir into a project path in the secrets repo. # Does NOT commit or push — caller handles that. push_dir_to_project() { local source_dir="$1" local project="$2" local pubkey="$3" if ! collect_env_files "$source_dir"; then return 1 fi info "$project: ${#COLLECTED_FILES[@]} file(s)" for f in "${COLLECTED_FILES[@]}"; do echo " $(basename "$f")" done mkdir -p "$SECRETS_DIR/$project" for f in "${COLLECTED_FILES[@]}"; do local name name=$(basename "$f") age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" done return 0 } # Git commit + push for the secrets repo. Shared by push and push --workspaces. commit_and_push_secrets() { local message="$1" if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then die "Fast-forward pull failed. Run 'secrets pull' first, then retry push." fi fi git -C "$SECRETS_DIR" add -A if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then info "No changes to push (secrets unchanged)" return fi git -C "$SECRETS_DIR" commit -m "$message" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then git -C "$SECRETS_DIR" push >/dev/null 2>&1 info "Pushed to remote" else info "Committed locally (no remote configured)" fi } cmd_push() { check_cmd age check_cmd git resolve_store check_initialized check_key local project project=$(derive_project_name "${1:-}") info "Pushing secrets for project: $project" echo_store_if_non_default local pubkey pubkey=$(get_pubkey) if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then die "No secret files (.env, .env.*, .dev.vars) found in $PWD" fi commit_and_push_secrets "update $project" } cmd_push_workspaces() { check_cmd age check_cmd git check_cmd jq resolve_store check_initialized check_key local root="$PWD" local monorepo_name monorepo_name=$(derive_project_name "") info "Pushing workspaces for monorepo: $monorepo_name" echo_store_if_non_default local pubkey pubkey=$(get_pubkey) local total=0 # Push root env files (if any) if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then total=$((total + ${#COLLECTED_FILES[@]})) fi # Push each workspace local workspaces workspaces=$(get_workspaces "$root") while IFS= read -r ws; do [ -n "$ws" ] || continue local ws_dir="$root/$ws" local ws_project="$monorepo_name/$ws" if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then total=$((total + ${#COLLECTED_FILES[@]})) fi done <<< "$workspaces" if [ "$total" -eq 0 ]; then die "No secret files found in any workspace" fi commit_and_push_secrets "update $monorepo_name workspaces" } cmd_pull() { check_cmd age check_cmd git resolve_store check_initialized check_key local project project=$(derive_project_name "${1:-}") local target_dir="$PWD" info "Pulling secrets for project: $project" echo_store_if_non_default # Pull latest if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then git -C "$SECRETS_DIR" pull >/dev/null 2>&1 fi # Check project exists if [ ! -d "$SECRETS_DIR/$project" ]; then die "Project '$project' not found. Run: secrets list" fi # Decrypt each .age file into target dir (including dotfiles) local count=0 for f in "$SECRETS_DIR/$project"/*.age "$SECRETS_DIR/$project"/.*.age; do [ -f "$f" ] || continue local name name=$(basename "$f" .age) local outfile="$target_dir/$name" age -d -i "$KEY_FILE" -o "$outfile" "$f" # Integrity check: verify non-empty if [ ! -s "$outfile" ]; then echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" fi count=$((count + 1)) done info "Decrypted $count file(s) into $target_dir" # Reinstall hook if missing if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then install_hook info "Reinstalled pre-commit hook" fi } # Pull and decrypt .age files from a project path into a target directory. # Does NOT do git pull — caller handles that. pull_project_to_dir() { local project="$1" local target_dir="$2" local project_dir="$SECRETS_DIR/$project" [ -d "$project_dir" ] || return 1 local count=0 for f in "$project_dir"/*.age "$project_dir"/.*.age; do [ -f "$f" ] || continue local name name=$(basename "$f" .age) local outfile="$target_dir/$name" age -d -i "$KEY_FILE" -o "$outfile" "$f" if [ ! -s "$outfile" ]; then echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" fi count=$((count + 1)) done echo "$count" } cmd_pull_workspaces() { check_cmd age check_cmd git check_cmd jq resolve_store check_initialized check_key local root="$PWD" local monorepo_name monorepo_name=$(derive_project_name "") info "Pulling workspaces for monorepo: $monorepo_name" echo_store_if_non_default # Pull latest from remote if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then git -C "$SECRETS_DIR" pull >/dev/null 2>&1 fi local total=0 # Pull root secrets (if any) if [ -d "$SECRETS_DIR/$monorepo_name" ]; then local count count=$(pull_project_to_dir "$monorepo_name" "$root") total=$((total + count)) info "$monorepo_name (root): $count file(s)" fi # Pull each workspace local workspaces workspaces=$(get_workspaces "$root") while IFS= read -r ws; do [ -n "$ws" ] || continue local ws_dir="$root/$ws" local ws_project="$monorepo_name/$ws" if [ -d "$SECRETS_DIR/$ws_project" ]; then local count count=$(pull_project_to_dir "$ws_project" "$ws_dir") total=$((total + count)) info "$ws_project: $count file(s)" fi done <<< "$workspaces" if [ "$total" -eq 0 ]; then die "No secrets found for any workspace in $monorepo_name" fi info "Decrypted $total file(s) total" # Reinstall hook if missing if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then install_hook info "Reinstalled pre-commit hook" fi } cmd_list() { resolve_store check_initialized local found=0 for dir in "$SECRETS_DIR"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") # Skip hidden dirs [[ "$project" == .* ]] && continue echo "$project:" for f in "$dir"*.age "$dir".*.age; do [ -f "$f" ] || continue echo " $(basename "$f" .age)" found=1 done done if [ "$found" -eq 0 ]; then echo "No projects found. Run 'secrets push ' to add one." fi # DX-7 hint: when a non-default store is active, point users at `secrets which`. if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then echo "" info "Showing projects in $SECRETS_DIR. Run 'secrets which' for details." fi } cmd_rm() { check_cmd git resolve_store check_initialized local project="${1:-}" [ -n "$project" ] || die "Usage: secrets rm " if [ ! -d "$SECRETS_DIR/$project" ]; then die "Project '$project' not found. Run: secrets list" fi info "Removing project: $project" git -C "$SECRETS_DIR" rm -r "$project/" >/dev/null git -C "$SECRETS_DIR" commit -m "remove $project" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then git -C "$SECRETS_DIR" push >/dev/null 2>&1 info "Removed $project from remote" else info "Removed $project locally (no remote configured)" fi } cmd_rekey() { check_cmd age check_cmd git resolve_store check_initialized check_key # Create temp dir with cleanup trap local tmpdir tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT INT TERM info "Decrypting all files with current key..." # Decrypt all .age files into temp dir local file_count=0 for dir in "$SECRETS_DIR"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") [[ "$project" == .* ]] && continue mkdir -p "$tmpdir/$project" for f in "$dir"*.age "$dir".*.age; do [ -f "$f" ] || continue local name name=$(basename "$f" .age) if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/$name" "$f"; then die "Decryption failed for $project/$name. Rekey aborted. Old key preserved." fi file_count=$((file_count + 1)) done done if [ "$file_count" -eq 0 ]; then die "No encrypted files found. Nothing to rekey." fi info "Decrypted $file_count file(s). Generating new key pair..." # Generate new key (overwrites old) age-keygen -o "$KEY_FILE" 2>&1 local pubkey pubkey=$(get_pubkey) info "Re-encrypting all files with new key..." # Re-encrypt all files for dir in "$tmpdir"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") mkdir -p "$SECRETS_DIR/$project" for f in "$dir"*; do [ -f "$f" ] || continue local name name=$(basename "$f") age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" done done # Commit and push git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then git -C "$SECRETS_DIR" push >/dev/null 2>&1 info "Pushed rekeyed secrets to remote" else info "Committed rekeyed secrets locally (no remote configured)" fi info "Rekey complete!" echo "" echo "IMPORTANT: Copy new key to your other machine:" echo " scp $KEY_FILE :$KEY_FILE" echo "" echo "WARNING: Old ciphertext remains in git history." echo "For full rotation, create a fresh repo." } cmd_clear() { local dir="$PWD" if ! collect_env_files "$dir"; then info "No secret files to clear in $dir" return fi local count=0 for f in "${COLLECTED_FILES[@]}"; do rm "$f" count=$((count + 1)) done info "Cleared $count secret file(s) from $dir" } cmd_clear_workspaces() { check_cmd jq local root="$PWD" local total=0 # Clear root if collect_env_files "$root"; then for f in "${COLLECTED_FILES[@]}"; do rm "$f" total=$((total + 1)) done fi # Clear each workspace local workspaces workspaces=$(get_workspaces "$root") while IFS= read -r ws; do [ -n "$ws" ] || continue local ws_dir="$root/$ws" if collect_env_files "$ws_dir"; then for f in "${COLLECTED_FILES[@]}"; do rm "$f" total=$((total + 1)) done fi done <<< "$workspaces" info "Cleared $total secret file(s) from workspace" } # EXIT-trap helpers for cmd_run. Defined as functions (not inline string # traps) so $_RUN_PWD is dereferenced safely regardless of special chars # in the path. F1: trap "cd '$X'" breaks when X contains a single quote. _run_cleanup() { cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear } _run_cleanup_workspaces() { cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear_workspaces } cmd_run() { local workspace_mode=false local project="" # Parse flags before the command while [ $# -gt 0 ]; do case "$1" in -w|--workspaces) workspace_mode=true; shift ;; --) shift; break ;; -*) die "Unknown flag: $1. Usage: secrets run [-w] [--] " ;; *) break ;; esac done [ $# -gt 0 ] || die "Usage: secrets run [-w] [--] " # F8: pin the project directory now so the EXIT trap clears the right # plaintext files even if the user's command does `cd` into another dir. # Use a global + named function (NOT string-interpolated trap) so paths # with special characters — apostrophes, dollar signs, spaces — work. # Single-quoting `$_RUN_PWD` into a string trap would break on any path # with a single quote (e.g. /Users/bri/it's-app), and the trap would # silently fail to clean up plaintext secrets. EGB-281 F1. _RUN_PWD="$PWD" # Pull secrets (cmd_pull/cmd_pull_workspaces call resolve_store internally) if [ "$workspace_mode" = true ]; then cmd_pull_workspaces else cmd_pull "$project" fi if [ "$workspace_mode" = true ]; then trap _run_cleanup_workspaces EXIT else trap _run_cleanup EXIT fi # Execute the command, capturing exit code (don't let set -e kill us) local rc=0 "$@" || rc=$? exit "$rc" } cmd_which() { resolve_store echo "store: $SECRETS_DIR" echo "source: $STORE_SOURCE" } cmd_help() { cat << 'EOF' secrets — encrypted secret file sync between machines Usage: secrets init Initialize the secrets repo and generate an age key secrets push [project] Encrypt secret files and push to the secrets repo secrets push -w|--workspaces Push secrets from all workspaces in package.json secrets pull [project] Pull and decrypt secret files into current directory secrets pull -w|--workspaces Pull secrets into all workspaces from package.json secrets clear Remove plaintext secret files from current directory secrets clear -w|--workspaces Clear secrets from all workspaces in package.json secrets run [-w] Pull secrets, run command, clear secrets on exit secrets list List all projects and their secret files secrets rm Remove a project's secrets from the repo secrets rekey Re-encrypt all secrets with a new key secrets which Show the active store path and which rule chose it secrets where Alias for `which` secrets status Alias for `which` Tracked files: .env, .env.*, .dev.vars If [project] is omitted, it is derived from the current directory's git remote (if available) or the directory name. Stores: Most users have one ~/.secrets/ store. To use a separate store (e.g. for work secrets vs personal), use any of these resolution rules (highest precedence first): 1. --store flag secrets --store ~/.secrets-work pull 2. .secrets-store file in project echo work > .secrets-store && git add ... 3. SECRETS_DIR env var (legacy) SECRETS_DIR=~/.secrets-work secrets pull 4. ~/.secrets default Bare names ("work") expand to ~/.secrets-work. The name "default" resolves to ~/.secrets. Run `secrets which` to inspect the active store. Workspaces: With -w/--workspaces, reads package.json "workspaces" field to find workspace directories. Each workspace's secret files are stored under // in the secrets repo. Root secret files are stored under / directly. Requires jq. Environment: SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also the .secrets-store file and --store flag above. EOF } # ─── Main ────────────────────────────────────────────────────────────── # Pre-pass: extract --store flag from anywhere in the args (before `--` only). # Lets `secrets --store work push`, `secrets push --store work`, and # `secrets run --store work cmd ...` all work consistently. After `--`, # args belong to the user's command and are passed through untouched. ARGS=() while [ $# -gt 0 ]; do case "$1" in --) # Stop pre-pass; pass `--` and everything after through untouched # so the user's command (e.g. `secrets run -- cmd --store foo`) is # not mangled. ARGS+=("$@") break ;; --store) [ $# -ge 2 ] || die "--store requires a directory or store name" # F3: reject values that look like another flag — almost always a typo # (`secrets --store push` → user dropped the value, would silently use # ~/.secrets-push and surface a confusing "not initialized" error). case "$2" in --|-*) die "--store value looks like a flag: $2 (did you forget the value?)" ;; esac STORE_OVERRIDE="$2" shift 2 ;; --store=*) # F4: --store= with empty value used to silently fall through to the # next rule. Treat it as a typo too. [ -n "${1#--store=}" ] || die "--store= requires a value" STORE_OVERRIDE="${1#--store=}" shift ;; *) ARGS+=("$1") shift ;; esac done # Re-set positional params. Bulletproof against empty array under `set -u`. if [ ${#ARGS[@]} -gt 0 ]; then set -- "${ARGS[@]}" else set -- fi case "${1:-help}" in init) cmd_init ;; push) if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then cmd_push_workspaces else cmd_push "${2:-}" fi ;; pull) if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then cmd_pull_workspaces else cmd_pull "${2:-}" fi ;; clear) if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then cmd_clear_workspaces else cmd_clear fi ;; run) shift cmd_run "$@" ;; list) cmd_list ;; rm) cmd_rm "${2:-}" ;; rekey) cmd_rekey ;; which|where|status) cmd_which ;; help|--help|-h) cmd_help ;; *) die "Unknown command: $1. Run 'secrets help' for usage." ;; esac