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>
This commit is contained in:
Brian Majewski 2026-05-09 14:30:28 -07:00 committed by GitHub
parent edb1614941
commit 7e6ddf3a12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 812 additions and 11 deletions

302
secrets
View file

@ -6,10 +6,27 @@ set -euo pipefail
# 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; }
@ -20,11 +37,37 @@ check_cmd() {
}
check_initialized() {
[ -d "$SECRETS_DIR/.git" ] || die "Not initialized. Run: secrets init"
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() {
[ -f "$KEY_FILE" ] || die "Key file not found at $KEY_FILE. Run: secrets init"
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() {
@ -48,6 +91,146 @@ derive_project_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.
@ -117,6 +300,7 @@ HOOKEOF
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."
@ -214,12 +398,14 @@ commit_and_push_secrets() {
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)
@ -235,6 +421,7 @@ cmd_push_workspaces() {
check_cmd age
check_cmd git
check_cmd jq
resolve_store
check_initialized
check_key
@ -242,6 +429,7 @@ cmd_push_workspaces() {
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)
@ -274,6 +462,7 @@ cmd_push_workspaces() {
cmd_pull() {
check_cmd age
check_cmd git
resolve_store
check_initialized
check_key
@ -281,6 +470,7 @@ cmd_pull() {
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
@ -344,6 +534,7 @@ cmd_pull_workspaces() {
check_cmd age
check_cmd git
check_cmd jq
resolve_store
check_initialized
check_key
@ -351,6 +542,7 @@ cmd_pull_workspaces() {
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
@ -396,6 +588,7 @@ cmd_pull_workspaces() {
}
cmd_list() {
resolve_store
check_initialized
local found=0
@ -416,10 +609,17 @@ cmd_list() {
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:-}"
@ -443,6 +643,7 @@ cmd_rm() {
cmd_rekey() {
check_cmd age
check_cmd git
resolve_store
check_initialized
check_key
@ -564,6 +765,16 @@ cmd_clear_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=""
@ -580,18 +791,26 @@ cmd_run() {
[ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"
# Pull secrets
# 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
# Set trap to clear secrets on exit (normal, error, interrupt, terminate)
if [ "$workspace_mode" = true ]; then
trap 'cmd_clear_workspaces' EXIT
trap _run_cleanup_workspaces EXIT
else
trap 'cmd_clear' EXIT
trap _run_cleanup EXIT
fi
# Execute the command, capturing exit code (don't let set -e kill us)
@ -600,6 +819,12 @@ cmd_run() {
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
@ -616,12 +841,28 @@ Usage:
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
@ -629,12 +870,58 @@ Workspaces:
are stored under <monorepo>/ directly. Requires jq.
Environment:
SECRETS_DIR Path to secrets repo (default: ~/.secrets)
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)
@ -665,6 +952,7 @@ case "${1:-help}" in
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