secrets/secrets
Brian Majewski 2a7afc34dd fix: pre-landing review fixes (heal ordering, key untrack, content-aware gitignore)
Red-team + specialist findings from /ship pre-landing review:

- CRITICAL: ensure_store_protections ran BEFORE `git pull --ff-only` in
  commit_and_push_secrets; a pull bringing history without .gitignore
  left the post-pull window unprotected and `git add -A` committed AND
  pushed key.txt (reproduced end-to-end). Heal now runs after the pull,
  immediately before staging.
- CRITICAL: .gitignore can't untrack an already-tracked key.txt. Heal
  now defensively `git rm --cached`s a tracked key with a history-scrub
  warning.
- Content-aware heal: a present .gitignore missing the key.txt line is
  rewritten, not skipped (security specialist).
- Init guard now renders the real sanitized clone URL from
  .secrets-store when available, mirroring EGB-282's check_initialized.
- Tests: positive heal-message assertions, non-empty key guard in the
  init test, 4 new regression tests (126 total).
2026-06-05 10:14:46 -07:00

1516 lines
51 KiB
Bash
Executable file

#!/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=""
# Remote URL parsed from the active .secrets-store file (optional 2nd token).
# Used by check_initialized to fill in a runnable `git clone <URL> <PATH>`
# in the missing-store error so teammates don't have to ask.
_REMOTE_URL=""
# --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
# Fill in the remote URL when .secrets-store provided one, so the
# teammate can copy-paste the clone command without asking the original
# setter for the URL. Falls back to a placeholder otherwise.
local clone_url
if [ -n "${_REMOTE_URL:-}" ]; then
clone_url="$_REMOTE_URL"
else
clone_url="<their-store-remote>"
fi
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 $clone_url $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 <dir> 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, optionally followed by a remote URL on the same line. 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)
#
# The optional second token (whitespace-separated) is a git remote URL. It
# is only used as a hint when the store directory does not yet exist on
# the current machine — the missing-store error includes a runnable
# `git clone <url> <path>` for the teammate to copy. Examples:
#
# work
# work git@github.com:acme/work-secrets.git
# ~/.secrets-work https://github.com/acme/work-secrets.git
#
# 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. On success, print "<store-spec>\t<remote-url>"
# (URL empty if not provided or rejected as unsafe). The first non-empty
# non-comment line is the active line; the first whitespace splits it into
# the spec and an optional URL.
#
# Security: the URL is later substituted into a copy-paste-ready `git clone`
# command in the missing-store error. An attacker who slips a malicious line
# into a committed .secrets-store could weaponize that copy-paste — e.g.
# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...`
# and a teammate following the directed error would execute the payload.
# We reject URLs containing shell metacharacters, control characters, ANSI
# escapes, and embedded whitespace. Rejected URLs are dropped silently from
# the parser's perspective (a warning is printed to stderr); the resolver
# falls back to the `<their-store-remote>` placeholder so the directed
# error stays useful without rendering the attacker-controlled string.
_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
# Split into spec (first whitespace-separated token) and url (rest of
# the line, verbatim). `read -r` does NOT glob-expand and preserves the
# tail in $rest as a single string — important so `work *` doesn't
# silently expand to `work file1 file2 ...`.
local spec="" rest=""
read -r spec rest <<< "$line"
local url="$rest"
if [ -n "$url" ]; then
# Reject URLs containing characters that could weaponize a
# copy-paste shell command, terminal escapes, or be ambiguous.
# Match order: control chars (incl. ESC \x1b), shell meta, whitespace.
case "$url" in
*[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*" "*)
echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2
url=""
;;
esac
fi
printf '%s\t%s\n' "$spec" "$url"
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>\t<source-file-path>\t<remote-url>"
# (URL empty if .secrets-store didn't include one) and returns 0. The caller
# (resolve_store) splits the tab-separated 3-tuple. We can't set a
# parent-shell variable from here because we're typically called inside
# `$(...)` command substitution, which runs in a subshell.
_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 parsed
if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then
# parsed is "<spec>\t<url>" (URL may be empty)
local spec="${parsed%%$'\t'*}"
local url="${parsed#*$'\t'}"
local expanded
expanded=$(_expand_store_path "$spec")
printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
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 (<path>)" | "SECRETS_DIR env var" | "default"
# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store
# (empty when not present). check_initialized uses _REMOTE_URL to fill in a
# copy-paste-ready `git clone` command for teammates whose store doesn't
# exist yet.
resolve_store() {
local resolved=""
local source=""
_REMOTE_URL=""
if [ -n "${STORE_OVERRIDE:-}" ]; then
resolved=$(_expand_store_path "$STORE_OVERRIDE")
source="--store flag"
_LAST_FOUND_AT=""
elif _find_result=$(_find_secrets_store_file); then
# _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
# IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
# save/restore needed. URL field is empty when .secrets-store didn't
# include a URL or when it was rejected as unsafe.
IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result"
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 ]
}
# ─── External files (.secrets-files) — EGB-531 ─────────────────────────
#
# A committed, project-local `.secrets-files` manifest declares files that
# live OUTSIDE the project root (e.g. global Gradle properties) whose
# *designated keys* should sync. One entry per non-empty non-comment line:
#
# gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
# <type> <path> <key> [key...]
#
# push extracts only the named keys → encrypts a subset blob under
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
# preserving every unrelated line. Only type `gradle-properties` is
# supported (the type token leaves room for future types without a plugin
# framework). Parsing mirrors `.secrets-store`: no shell expansion, no
# symlink following, conservative sanitization. The merge is pure bash
# (no sed/regex) with exact-string key matching, so it is safe against
# values containing `& \ /` and against substring keys
# (beaconClerkPk vs beaconClerkPkTest).
SECRETS_FILES_NAME=".secrets-files"
SUPPORTED_EXTERNAL_TYPE="gradle-properties"
# True if the string ends in an odd number of backslashes (a Java/Gradle
# properties line-continuation). Used to skip continuation lines when
# matching keys so an unrelated continued value is never clobbered.
_trailing_bs_odd() {
local s="$1" n=0
while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done
[ $((n % 2)) -eq 1 ]
}
# Parse .secrets-files. Emits "<type>\t<path>\t<keys>" per valid entry on
# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects
# shell/control chars and `..` in the path, restricts keys to a safe
# charset. No shell expansion is applied to file content.
_parse_secrets_files_manifest() {
local file="$1"
local line lineno=0 mtype mpath mkeys
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
line="${line%$'\r'}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
case "$line" in '#'*) continue ;; esac
# `read -r` does not glob-expand and keeps the key list intact in $mkeys.
read -r mtype mpath mkeys <<< "$line"
if [ "$mtype" != "$SUPPORTED_EXTERNAL_TYPE" ]; then
echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPE). Skipping." >&2
continue
fi
if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
echo "WARNING: $file line $lineno: expected '<type> <path> <key> [key...]'. Skipping." >&2
continue
fi
# Path: allow alnum and / . _ ~ - only; reject everything else (blocks
# \$ \` ; & | ( ) * ? whitespace etc.) and reject `..` traversal.
case "$mpath" in
*[!A-Za-z0-9/._~-]* | *..*)
echo "WARNING: $file line $lineno: unsafe characters in path. Paths may contain letters, digits, / . _ - ~ only. Skipping." >&2
continue
;;
esac
# Keys: space-separated; each may contain alnum and . _ - (covers the
# systemProp. prefix). Reject anything else (=, *, control chars, ...).
case "$mkeys" in
*[!A-Za-z0-9._\ -]*)
echo "WARNING: $file line $lineno: unsafe characters in key list. Skipping." >&2
continue
;;
esac
printf '%s\t%s\t%s\n' "$mtype" "$mpath" "$mkeys"
done < "$file"
}
# Validate a writable external target path. Refuses anything that isn't a
# `gradle.properties` resolving inside $HOME, blocks `..`, and refuses
# symlinks (target file or its parent dir) — the path comes from a
# committed file, so it is attacker-controllable. Returns 0 if safe.
_validate_external_target_path() {
local p="$1"
local base; base=$(basename "$p")
if [ "$base" != "gradle.properties" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target basename must be 'gradle.properties' (got '$base'). Refusing." >&2
return 1
fi
case "$p" in *..*) echo "ERROR: $SECRETS_FILES_NAME: target path may not contain '..'. Refusing." >&2; return 1 ;; esac
local home_real; home_real=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_real="$HOME"
local dir; dir=$(dirname "$p")
# Resolve the deepest existing ancestor and require it inside $HOME.
local probe="$dir"
while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do
probe=$(dirname "$probe")
done
local probe_real
probe_real=$(cd -P "$probe" 2>/dev/null && pwd -P) || {
echo "ERROR: $SECRETS_FILES_NAME: cannot resolve target ancestor '$probe'. Refusing." >&2
return 1
}
case "$probe_real/" in
"$home_real"/*) ;;
*) echo "ERROR: $SECRETS_FILES_NAME: target '$p' must resolve inside \$HOME. Refusing." >&2; return 1 ;;
esac
if [ -L "$p" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target '$p' is a symlink. Refusing to follow." >&2
return 1
fi
if [ -e "$dir" ] && [ -L "$dir" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target dir '$dir' is a symlink. Refusing." >&2
return 1
fi
return 0
}
# Print the value of an exact key from a properties file (last definition
# wins, matching Gradle). Returns 1 if the key is absent. Pure bash; the
# key is compared by string equality (never as a regex), and the value is
# read verbatim. Skips comments and continuation lines.
_props_get() {
local file="$1" want="$2"
[ -f "$file" ] || return 1
local line t key after val="" found=0 cont=0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
if [ "$cont" -eq 1 ]; then
_trailing_bs_odd "$line" && cont=1 || cont=0
continue
fi
_trailing_bs_odd "$line" && cont=1 || cont=0
t="${line#"${line%%[![:space:]]*}"}"
case "$t" in ''|'#'*|'!'*) continue ;; esac
key="${t%%[=:[:space:]]*}"
[ "$key" = "$want" ] || continue
after="${t#"$key"}"
after="${after#"${after%%[![:space:]]*}"}"
case "$after" in [=:]*) after="${after#?}" ;; esac
after="${after#"${after%%[![:space:]]*}"}"
val="$after"
found=1
done < "$file"
[ "$found" -eq 1 ] || return 1
printf '%s' "$val"
}
# Turn a manifest path token into a machine-independent blob slug.
# A bare char-replace would collide (e.g. a/b and a_b both → a_b), so append a
# checksum of the original path to keep distinct targets' blobs distinct.
_secrets_files_slug() {
local p="$1"
local clean="${p//[!A-Za-z0-9._-]/_}"
local sum
sum=$(printf '%s' "$p" | cksum | cut -d' ' -f1)
printf '%s-%s' "$clean" "$sum"
}
# Merge managed key=value lines (from $2) into target file $1, preserving
# all unrelated lines/comments/order. Updates a managed key in place (first
# occurrence), collapses duplicates, appends new keys. Atomic + mode-safe.
merge_gradle_keys() {
local target="$1" kvfile="$2"
local -a mkey=() mval=() seen=()
local line key val i
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
key="${line%%=*}"
val="${line#*=}"
mkey+=("$key"); mval+=("$val"); seen+=(0)
done < "$kvfile"
local n=${#mkey[@]}
[ "$n" -gt 0 ] || return 0
local dir; dir=$(dirname "$target")
mkdir -p "$dir"
local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1
local cont=0 t tkey matched
if [ -f "$target" ]; then
while IFS= read -r line || [ -n "$line" ]; do
local stripped="${line%$'\r'}"
if [ "$cont" -eq 1 ]; then
printf '%s\n' "$line" >> "$tmp"
_trailing_bs_odd "$stripped" && cont=1 || cont=0
continue
fi
_trailing_bs_odd "$stripped" && cont=1 || cont=0
t="${stripped#"${stripped%%[![:space:]]*}"}"
case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac
tkey="${t%%[=:[:space:]]*}"
matched=-1
for ((i = 0; i < n; i++)); do
if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi
done
if [ "$matched" -ge 0 ]; then
if [ "${seen[$matched]}" -eq 0 ]; then
printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp"
seen[$matched]=1
fi
else
printf '%s\n' "$line" >> "$tmp"
fi
done < "$target"
fi
for ((i = 0; i < n; i++)); do
if [ "${seen[$i]}" -eq 0 ]; then
printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp"
fi
done
# Back up the existing target before overwriting (seatbelt for the merge
# engine touching a hand-maintained file), preserve mode, atomic rename.
if [ -f "$target" ]; then
cp "$target" "$target.secrets-bak" 2>/dev/null || true
local mode
mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600)
chmod "$mode" "$tmp" 2>/dev/null || true
else
chmod 600 "$tmp" 2>/dev/null || true
fi
# TOCTOU recheck before the write into $HOME.
if [ -L "$target" ]; then
rm -f "$tmp"
echo "ERROR: $target became a symlink; aborting merge." >&2
return 1
fi
mv "$tmp" "$target"
}
# Encrypt the managed keys declared in <root>/.secrets-files into
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() {
local root="$1" project="$2" pubkey="$3"
local manifest="$root/$SECRETS_FILES_NAME"
[ -e "$manifest" ] || return 1
if [ -L "$manifest" ]; then
echo "WARNING: $manifest is a symlink; ignoring." >&2
return 1
fi
[ -f "$manifest" ] || return 1
local pushed=0 mtype mpath mkeys
while IFS=$'\t' read -r mtype mpath mkeys; do
[ -n "$mtype" ] || continue
local expanded; expanded=$(_expand_store_path "$mpath")
if ! _validate_external_target_path "$expanded"; then
die "Refusing unsafe external target in $SECRETS_FILES_NAME: $mpath"
fi
if [ ! -f "$expanded" ]; then
echo "WARNING: source '$mpath' not found on this machine; skipping." >&2
continue
fi
local tmp; tmp=$(mktemp)
local found=0 k v
for k in $mkeys; do
if v=$(_props_get "$expanded" "$k"); then
if _trailing_bs_odd "$v"; then
# A trailing odd backslash means a multi-line (continuation) value.
# We only support single-line values; syncing this would write a
# dangling backslash that turns the next target line into a
# continuation and corrupts the file. Skip it loudly.
echo "WARNING: key '$k' in $mpath has a multi-line (continuation) value — not supported, skipping." >&2
else
printf '%s=%s\n' "$k" "$v" >> "$tmp"
found=$((found + 1))
fi
else
echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2
fi
done
if [ "$found" -eq 0 ]; then
rm -f "$tmp"
die "No managed keys found in $mpath (looked for: $mkeys)."
fi
mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$mtype.age" "$tmp"
rm -f "$tmp"
info "Extracted $found key(s) from $mpath"
pushed=$((pushed + 1))
done < <(_parse_secrets_files_manifest "$manifest")
[ "$pushed" -gt 0 ]
}
# Decrypt the external blobs named by <root>/.secrets-files and merge their
# keys into the declared targets. Skips (with a warning) unsafe targets or
# missing blobs rather than aborting the whole pull.
pull_external_files() {
local root="$1" project="$2"
local manifest="$root/$SECRETS_FILES_NAME"
[ -e "$manifest" ] || return 0
if [ -L "$manifest" ]; then
echo "WARNING: $manifest is a symlink; ignoring." >&2
return 0
fi
[ -f "$manifest" ] || return 0
local mtype mpath mkeys
while IFS=$'\t' read -r mtype mpath mkeys; do
[ -n "$mtype" ] || continue
local expanded; expanded=$(_expand_store_path "$mpath")
if ! _validate_external_target_path "$expanded"; then
echo "WARNING: skipping unsafe external target: $mpath" >&2
continue
fi
local slug; slug=$(_secrets_files_slug "$mpath")
local blob="$SECRETS_DIR/$project/external/$slug.$mtype.age"
if [ ! -f "$blob" ]; then
echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2
continue
fi
local tmp; tmp=$(mktemp)
if ! age -d -i "$KEY_FILE" -o "$tmp" "$blob"; then
rm -f "$tmp"
die "Decryption failed for external target $mpath."
fi
local k k_esc
for k in $mkeys; do
# Escape regex-special '.' so e.g. systemProp.foo can't match systemPropXfoo.
k_esc=$(printf '%s' "$k" | sed 's/\./\\./g')
grep -q "^$k_esc=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2
done
if [ ! -d "$(dirname "$expanded")" ]; then
echo "WARNING: creating $(dirname "$expanded")" >&2
fi
local count; count=$(grep -c '=' "$tmp" 2>/dev/null || true); count=${count:-0}
if merge_gradle_keys "$expanded" "$tmp"; then
local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g')
info "Merged $count key(s) into $expanded ($klist)"
else
echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2
fi
rm -f "$tmp"
done < <(_parse_secrets_files_manifest "$manifest")
}
# 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|gradle\.properties)' | 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
}
# Write the store-level .gitignore. Critical: the `key.txt` line is what
# keeps the private key out of `git add -A` during push/rekey.
write_store_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
}
# Restore store-level protections if missing. A cloned store has no
# pre-commit hook (hooks aren't cloned), and a half-initialized store may
# lack .gitignore — without it, `git add -A` would commit key.txt.
ensure_store_protections() {
# Content-aware: a present-but-corrupted .gitignore missing the key.txt
# line is just as dangerous as a missing one.
if [ ! -f "$SECRETS_DIR/.gitignore" ] || ! grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"; then
write_store_gitignore
info "Restored store .gitignore"
fi
# .gitignore can't untrack an already-tracked key (legacy damage, or a
# past window where .gitignore was missing). Remove it from the index so
# the next commit drops it from the tip.
if git -C "$SECRETS_DIR" ls-files --error-unmatch key.txt >/dev/null 2>&1; then
git -C "$SECRETS_DIR" rm --cached --quiet key.txt
echo "WARNING: key.txt was tracked in the store repo — untracked it now." >&2
echo "It may still exist in git history; consider 'secrets rekey' and scrubbing history." >&2
fi
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
mkdir -p "$SECRETS_DIR/.git/hooks"
install_hook
info "Reinstalled pre-commit hook"
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
# Second-machine trap: a copied key.txt without a repo means the user
# should clone their existing secrets repo, not init a fresh one.
# Catch it BEFORE git init so we don't leave a half-initialized store.
if [ -f "$KEY_FILE" ]; then
# Render a runnable clone command when .secrets-store carried a remote
# URL (already sanitized by resolve_store), mirroring check_initialized.
local clone_src="<your-secrets-remote>"
[ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL"
die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR.
If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead:
git clone $clone_src $SECRETS_DIR
Your key file has been left untouched."
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
write_store_gitignore
# 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 <url>"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
echo " 3. Run 'secrets push <project>' 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
# Must run AFTER the pull and immediately before `git add -A`: the pull
# can remove or alter .gitignore (remote history that lacks it), and a
# store missing the key.txt line would stage and push the private key.
ensure_store_protections
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)
local did=0
if push_dir_to_project "$PWD" "$project" "$pubkey"; then did=1; fi
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
if [ "$did" -eq 0 ]; then
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries 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"
# External files (.secrets-files) are monorepo-root-scoped, like
# .secrets-store — handle once, not per-workspace.
if push_external_files "$root" "$monorepo_name" "$pubkey"; then
total=$((total + 1))
fi
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"
# Merge any external files (.secrets-files) declared in this project.
pull_external_files "$PWD" "$project"
# Reinstall hook / store .gitignore if missing
ensure_store_protections
}
# 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"
# External files (.secrets-files) are monorepo-root-scoped — once.
if [ -f "$root/$SECRETS_FILES_NAME" ] && [ ! -L "$root/$SECRETS_FILES_NAME" ]; then
pull_external_files "$root" "$monorepo_name"
total=$((total + 1))
fi
if [ "$total" -eq 0 ]; then
die "No secrets found for any workspace in $monorepo_name"
fi
info "Decrypted $total file(s) total"
# Reinstall hook / store .gitignore if missing
ensure_store_protections
}
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
# External files live in a subdir, invisible to the globs above.
if [ -d "${dir}external" ]; then
for f in "${dir}external"/*.age; do
[ -f "$f" ] || continue
echo " [external] $(basename "$f" .age)"
found=1
done
fi
done
if [ "$found" -eq 0 ]; then
echo "No projects found. Run 'secrets push <project>' 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 <project>"
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)
# `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the
# function returns (the local would be out of scope). Normal completion
# cleans up explicitly and clears the trap at the end of this function.
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
# External files live in a subdir; rekey them too or they become
# undecryptable after rotation.
if [ -d "${dir}external" ]; then
mkdir -p "$tmpdir/$project/external"
for f in "${dir}external"/*.age; do
[ -f "$f" ] || continue
local ename
ename=$(basename "$f" .age)
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/external/$ename" "$f"; then
die "Decryption failed for $project/external/$ename. Rekey aborted. Old key preserved."
fi
file_count=$((file_count + 1))
done
fi
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 the new key. age-keygen refuses to overwrite an existing
# file, so generate into the temp dir and move it into place only on
# success — the old key stays intact if generation fails.
local newkey="$tmpdir/key.txt.new"
age-keygen -o "$newkey" 2>&1
mv "$newkey" "$KEY_FILE"
local pubkey
pubkey=$(get_pubkey)
info "Re-encrypting all files with new key..."
# Re-encrypt all files. The ".*" glob is required: dotenv files decrypt
# to dotfiles ("$tmpdir/p/.env") that a bare "*" would silently skip,
# leaving their blobs on the old key (undecryptable after rotation).
for dir in "$tmpdir"/*/; do
[ -d "$dir" ] || continue
local project
project=$(basename "$dir")
mkdir -p "$SECRETS_DIR/$project"
for f in "$dir"* "$dir".*; do
[ -f "$f" ] || continue
local name
name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done
if [ -d "${dir}external" ]; then
mkdir -p "$SECRETS_DIR/$project/external"
for f in "${dir}external"/*; do
[ -f "$f" ] || continue
local ename
ename=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/${ename}.age" "$f"
done
fi
done
# Commit and push (heal .gitignore first so add -A can't stage key.txt)
ensure_store_protections
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 <other-machine>:$KEY_FILE"
echo ""
echo "WARNING: Old ciphertext remains in git history."
echo "For full rotation, create a fresh repo."
# Clean up the plaintext temp dir while $tmpdir is still in scope, then
# drop the trap so it can't fire (and error under set -u) post-return.
rm -rf "$tmpdir"
trap - EXIT INT TERM
}
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] [--] <command...>" ;;
*) break ;;
esac
done
[ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"
# 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"
# Read back any external-file manifest in cwd (validates the format and
# gives the user a way to confirm it parsed, since there's no add-file
# command). Skips symlinked manifests.
local manifest="$PWD/$SECRETS_FILES_NAME"
if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then
local mtype mpath mkeys printed=0
while IFS=$'\t' read -r mtype mpath mkeys; do
[ -n "$mtype" ] || continue
if [ "$printed" -eq 0 ]; then
echo "external files ($SECRETS_FILES_NAME at $manifest):"
printed=1
fi
echo " $mtype $mpath $mkeys"
done < <(_parse_secrets_files_manifest "$manifest")
fi
}
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] <command> Pull secrets, run command, clear secrets on exit
secrets list List all projects and their secret files
secrets rm <project> 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 <dir> 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.
Optional .secrets-store URL hint:
A second whitespace-separated token on the line is treated as the
store's git remote URL. It is used to fill in a runnable `git clone
<url> <path>` in the missing-store error so teammates joining the
project don't have to ask for the URL. Example:
work git@github.com:acme/work-secrets.git
Workspaces:
With -w/--workspaces, reads package.json "workspaces" field to find
workspace directories. Each workspace's secret files are stored under
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
are stored under <monorepo>/ directly. Requires jq.
External files (.secrets-files):
Sync specific keys from files OUTSIDE the project root (e.g. global
Gradle properties). Create a committed .secrets-files in the project
root, one entry per line:
# <type> <path> <keys...>
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
On push, the named keys are extracted from <path> and encrypted under
<project>/external/ in the store. On pull, they are MERGED back into
<path>, leaving all unrelated keys untouched. Only type
'gradle-properties' is supported; the target basename must be
'gradle.properties' and must resolve inside $HOME. Run 'secrets which'
from the project to confirm the manifest parsed.
Note: merged keys are written as permanent plaintext into the target
file (suitable for publishable/low-secrecy values). 'secrets clear'
does NOT remove them.
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