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:
parent
edb1614941
commit
7e6ddf3a12
7 changed files with 812 additions and 11 deletions
302
secrets
302
secrets
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue