secrets/secrets
Brian Majewski 7e6ddf3a12
v0.1.0.0 feat: multi-store support (EGB-281) (#1)
* feat: multi-store support via .secrets-store + --store flag

Layer four-rule store resolution on top of the existing SECRETS_DIR primitive
so users can manage multiple isolated encrypted stores (work vs personal,
per-client, etc.) without giving up the tool's small-bash-script pitch.

Resolution order (highest first):
  1. --store <dir>  flag (parsed in main pre-pass)
  2. .secrets-store file in cwd or any ancestor up to $HOME
  3. SECRETS_DIR    env var (legacy escape hatch)
  4. ~/.secrets     default

resolve_store() updates both SECRETS_DIR and KEY_FILE so existing single-store
codepaths just work. New cmd_which / where / status report the active store.
cmd_init, push, pull, push_workspaces, pull_workspaces, list, rm, rekey, run,
which all call resolve_store at entry.

Hardening from the EGB-281 adversarial review:
- F1: cmd_run EXIT trap is now a named function (not string-interpolated),
  so paths with apostrophes still get plaintext cleaned up
- F2: symlinked .secrets-store files are skipped, never read
- F3/F4: --store flag rejects flag-shaped values and empty --store=
- F5: HOME unset is detected up-front with a directed error
- F11: check_initialized / check_key give context-aware errors that name
  both recovery paths (git clone vs secrets init) when a teammate clones
  a project bound to a non-existent store on their machine

Tests: 37 → 66 (29 new). HOME=\$TEST_TMPDIR added to test setup so the
walk-up logic stays bounded inside fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add Multiple stores section to README

Five subsections walk users through: how store resolution works, how to
set up a second store on a machine, how to bind a project, how teammates
join a bound project, and how to undo or change a binding. SECRETS_DIR
table entry now points readers at the new --store flag and .secrets-store
file as the preferred mechanisms.

* chore: bump version and changelog (v0.1.0.0)

First formal release. EGB-281 adds multi-store support; this commit
seeds the VERSION file (4-digit MAJOR.MINOR.PATCH.MICRO) and the
CHANGELOG.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:30:28 -07:00

958 lines
27 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=""
# --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 <their-store-remote> $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. 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 (<path>)" | "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 <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
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 <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)
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 <other-machine>:$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] [--] <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"
}
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.
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.
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