Pre-landing review (testing + checklist specialists, reproduced) caught a data-loss bug: cmd_rekey's decrypt/re-encrypt globs were non-recursive and only special-cased external/. Nested manifest dotenv blobs (<project>/<relpath>.age, new this branch) were never visited, so after a key rotation they stayed encrypted under the discarded old key = permanently undecryptable. cmd_list had the same blind spot (cosmetic: nested entries invisible in listings). Both now walk the entire project tree with `find -type f` (bash 3.2 safe, includes dotfiles natively), unifying top-level / nested / external blobs into one recursive pass and dropping the now-redundant external/ special-casing. Regression tests: nested-blob rekey round-trip (survives rotation) + list shows nested entry. Full suite 193/193.
2100 lines
75 KiB
Bash
Executable file
2100 lines
75 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 && return
|
||
# Platform-aware install hint — hardcoding brew is wrong guidance on a
|
||
# Linux box or CI runner (EGB-677 DX review).
|
||
local hint="install '$1' with your package manager"
|
||
if command -v brew >/dev/null 2>&1; then
|
||
hint="brew install $1"
|
||
elif command -v apt-get >/dev/null 2>&1; then
|
||
hint="sudo apt-get install $1"
|
||
elif command -v dnf >/dev/null 2>&1; then
|
||
hint="sudo dnf install $1"
|
||
fi
|
||
die "'$1' is not installed. Run: $hint"
|
||
}
|
||
|
||
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. 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).
|
||
#
|
||
# A second type, `file` (EGB-652), syncs a WHOLE file outside the project
|
||
# root (e.g. an Android upload keystore) — binary-safe via age, no keys:
|
||
#
|
||
# file ~/keystores/beacon-upload.keystore
|
||
#
|
||
# push encrypts the file verbatim; pull restores it (mode 600, existing
|
||
# target backed up to <name>.secrets-bak first). Same path rules as
|
||
# gradle-properties (inside $HOME, no `..`, no symlinks) minus the
|
||
# basename restriction.
|
||
|
||
SECRETS_FILES_NAME=".secrets-files"
|
||
SUPPORTED_EXTERNAL_TYPES="gradle-properties file"
|
||
|
||
# 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"
|
||
case "$mtype" in
|
||
gradle-properties)
|
||
if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
|
||
echo "WARNING: $file line $lineno: expected 'gradle-properties <path> <key> [key...]'. Skipping." >&2
|
||
continue
|
||
fi
|
||
;;
|
||
file)
|
||
if [ -z "$mpath" ]; then
|
||
echo "WARNING: $file line $lineno: expected 'file <path>'. Skipping." >&2
|
||
continue
|
||
fi
|
||
if [ -n "$mkeys" ]; then
|
||
echo "WARNING: $file line $lineno: 'file' entries take no keys (got '$mkeys'). Skipping." >&2
|
||
continue
|
||
fi
|
||
;;
|
||
*)
|
||
echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPES). Skipping." >&2
|
||
continue
|
||
;;
|
||
esac
|
||
# 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. Requires the path to resolve
|
||
# inside $HOME, blocks `..`, and refuses symlinks (target file or its
|
||
# parent dir) — the path comes from a committed file, so it is
|
||
# attacker-controllable. For type `gradle-properties` the basename must be
|
||
# 'gradle.properties' (type `file` allows any basename — the whole file is
|
||
# replaced, never merged). Returns 0 if safe.
|
||
_validate_external_target_path() {
|
||
local p="$1" mtype="${2:-gradle-properties}"
|
||
local base; base=$(basename "$p")
|
||
if [ "$mtype" = "gradle-properties" ]; then
|
||
# EGB-677: generalized from exact 'gradle.properties' to any
|
||
# '*.properties' basename — still blocks merging key=value lines
|
||
# into ~/.bashrc / ~/.gitconfig style targets.
|
||
case "$base" in
|
||
*.properties) ;;
|
||
*)
|
||
echo "ERROR: properties target basename must end in '.properties' (got '$base'). Refusing." >&2
|
||
return 1
|
||
;;
|
||
esac
|
||
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"
|
||
# Entries come from .secrets.json (EGB-677) plus any legacy
|
||
# .secrets-files entries the manifest doesn't cover yet.
|
||
local entries
|
||
entries=$(_external_entries_for_push "$root")
|
||
[ -n "$entries" ] || 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" "$mtype"; 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
|
||
if [ "$mtype" = "file" ]; then
|
||
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
|
||
mkdir -p "$SECRETS_DIR/$project/external"
|
||
local fslug; fslug=$(_secrets_files_slug "$mpath")
|
||
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$fslug.file.age" "$expanded"
|
||
info "Encrypted file $mpath"
|
||
pushed=$((pushed + 1))
|
||
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 <<< "$entries"
|
||
|
||
[ "$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"
|
||
# .secrets.json wins entirely when present (EGB-677); legacy
|
||
# .secrets-files only drives manifest-less projects.
|
||
local entries
|
||
entries=$(_external_entries_for_pull "$root")
|
||
[ -n "$entries" ] || 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" "$mtype"; 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
|
||
if [ "$mtype" = "file" ]; then
|
||
# EGB-652: whole-file restore — decrypt next to the target (same
|
||
# filesystem -> atomic mv), back up any existing target, mode 600,
|
||
# TOCTOU symlink recheck before the write into $HOME.
|
||
local fdir; fdir=$(dirname "$expanded")
|
||
mkdir -p "$fdir"
|
||
local ftmp; ftmp=$(mktemp "$fdir/.secrets-file.XXXXXX") || { echo "WARNING: mktemp failed for $mpath; skipping." >&2; continue; }
|
||
if ! age -d -i "$KEY_FILE" -o "$ftmp" "$blob"; then
|
||
rm -f "$ftmp"
|
||
die "Decryption failed for external target $mpath."
|
||
fi
|
||
chmod 600 "$ftmp" 2>/dev/null || true
|
||
if [ -L "$expanded" ]; then
|
||
rm -f "$ftmp"
|
||
echo "WARNING: $expanded became a symlink; skipping restore." >&2
|
||
continue
|
||
fi
|
||
if [ -f "$expanded" ] && ! cmp -s "$expanded" "$ftmp"; then
|
||
cp "$expanded" "$expanded.secrets-bak" 2>/dev/null || true
|
||
fi
|
||
mv "$ftmp" "$expanded"
|
||
info "Restored file $expanded"
|
||
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 <<< "$entries"
|
||
}
|
||
|
||
# ─── Manifest (.secrets.json) — EGB-677 store format v2, stage 1 ──────
|
||
#
|
||
# A committed, project-root JSON manifest declaring everything the project
|
||
# syncs. Stage 1 is ADDITIVE: the manifest is read alongside the existing
|
||
# dotenv globs and `.secrets-files`; the v1 store layout is unchanged.
|
||
#
|
||
# {
|
||
# "version": 2,
|
||
# "options": { "autoAdd": true },
|
||
# "dotenv": [".env", "packages/web/.env.development"],
|
||
# "external": [
|
||
# { "type": "properties", "path": "~/.gradle/gradle.properties",
|
||
# "keys": ["beaconClerkPk"] },
|
||
# { "type": "file", "path": "~/keystores/beacon-upload.keystore" }
|
||
# ]
|
||
# }
|
||
#
|
||
# Security posture mirrors `.secrets-store`/`.secrets-files`: symlinked
|
||
# manifests are refused, dotenv paths are confined to the project root
|
||
# (no `..`, no absolute paths, conservative charset — `@` allowed for
|
||
# npm-scoped workspace dirs), and every jq-extracted string is
|
||
# re-validated before any filesystem use. jq is required only when a
|
||
# manifest exists or is being written — manifest-less projects keep
|
||
# working with zero new dependencies.
|
||
|
||
SECRETS_JSON_NAME=".secrets.json"
|
||
MANIFEST_VERSION=2
|
||
|
||
# Validate a project-relative dotenv path from the manifest (or `secrets
|
||
# add`). The path is attacker-influenced in team repos (committed file),
|
||
# so this is a security rail, not just hygiene. Prints an error and
|
||
# returns 1 when unsafe.
|
||
_validate_dotenv_rel_path() {
|
||
local p="$1"
|
||
if [ -z "$p" ]; then
|
||
echo "ERROR: $SECRETS_JSON_NAME: empty dotenv path." >&2
|
||
return 1
|
||
fi
|
||
case "$p" in
|
||
/*)
|
||
echo "ERROR: $SECRETS_JSON_NAME: '$p' is absolute — dotenv paths must be project-relative." >&2
|
||
return 1
|
||
;;
|
||
*..*)
|
||
echo "ERROR: $SECRETS_JSON_NAME: '$p' contains '..' — dotenv paths must be project-relative (no traversal)." >&2
|
||
return 1
|
||
;;
|
||
*[!A-Za-z0-9@/._-]*)
|
||
echo "ERROR: $SECRETS_JSON_NAME: unsafe characters in '$p'. Paths may contain letters, digits, @ / . _ - only." >&2
|
||
return 1
|
||
;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# Validate the manifest file itself (existence assumed checked by caller):
|
||
# refuse symlinks, malformed JSON, and unsupported schema versions — each
|
||
# with a directed error. $1 = manifest path.
|
||
_check_manifest_file() {
|
||
local manifest="$1"
|
||
if [ -L "$manifest" ]; then
|
||
die "$manifest is a symlink. Refusing to read it.
|
||
A committed symlink could point the manifest at attacker-chosen content.
|
||
Replace it with a regular file."
|
||
fi
|
||
check_cmd jq
|
||
local jq_err
|
||
if ! jq_err=$(jq -e . "$manifest" 2>&1 >/dev/null); then
|
||
die "$manifest: invalid JSON.
|
||
$jq_err
|
||
Fix the syntax (or delete the file and re-run 'secrets add' / 'secrets push')."
|
||
fi
|
||
local ver
|
||
ver=$(jq -r '.version // "missing"' "$manifest")
|
||
if [ "$ver" != "$MANIFEST_VERSION" ]; then
|
||
die "$manifest: manifest version $ver is not supported.
|
||
This client supports version $MANIFEST_VERSION.
|
||
If the manifest was written by a newer secrets, upgrade this machine:
|
||
git -C $SCRIPT_DIR pull"
|
||
fi
|
||
}
|
||
|
||
# Canonically (re)write the manifest: sorted keys, sorted+deduped dotenv,
|
||
# atomic tmp+mv in the project dir. stdin = the new JSON document.
|
||
_write_manifest_canonical() {
|
||
local manifest="$1"
|
||
local dir; dir=$(dirname "$manifest")
|
||
local tmp; tmp=$(mktemp "$dir/.secrets-json.XXXXXX") || return 1
|
||
if ! jq --sort-keys '.dotenv |= ((. // []) | unique | sort)' > "$tmp"; then
|
||
rm -f "$tmp"
|
||
return 1
|
||
fi
|
||
mv "$tmp" "$manifest"
|
||
}
|
||
|
||
# `secrets add <path>` — the explicit manifest writer. Creates
|
||
# .secrets.json on first use; validates and dedupes thereafter.
|
||
cmd_add() {
|
||
local p="${1:-}"
|
||
[ -n "$p" ] || die "Usage: secrets add <project-relative-path>"
|
||
# Normalize a leading ./
|
||
p="${p#./}"
|
||
_validate_dotenv_rel_path "$p" || exit 1
|
||
if [ ! -f "$PWD/$p" ]; then
|
||
die "'$p' not found in $PWD.
|
||
Create the file first, then re-run: secrets add $p"
|
||
fi
|
||
check_cmd jq
|
||
local manifest="$PWD/$SECRETS_JSON_NAME"
|
||
if [ -e "$manifest" ]; then
|
||
_check_manifest_file "$manifest"
|
||
jq --arg p "$p" '.dotenv = ((.dotenv // []) + [$p])' "$manifest" \
|
||
| _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
|
||
else
|
||
jq -n --arg p "$p" '{version: '"$MANIFEST_VERSION"', dotenv: [$p]}' \
|
||
| _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
|
||
fi
|
||
info "Added '$p' to $SECRETS_JSON_NAME"
|
||
info "Commit the manifest so other machines pick it up. To undo: edit $SECRETS_JSON_NAME and remove the entry."
|
||
}
|
||
|
||
# Emit "<type>\t<path>\t<keys>" tuples from a .secrets.json external[]
|
||
# array — the same wire format _parse_secrets_files_manifest produces, so
|
||
# push_external_files / pull_external_files consume either source
|
||
# unchanged. JSON type 'properties' maps to the legacy tuple token
|
||
# 'gradle-properties' so blob suffixes (and existing store blobs) stay
|
||
# stable in stage 1. Applies the same conservative charset checks as the
|
||
# legacy parser — jq guarantees well-formed JSON, not safe VALUES.
|
||
_json_external_entries() {
|
||
local manifest="$1"
|
||
check_cmd jq
|
||
local etype epath ekeys
|
||
while IFS=$'\t' read -r etype epath ekeys; do
|
||
[ -n "$etype" ] || continue
|
||
case "$etype" in
|
||
properties|gradle-properties)
|
||
etype="gradle-properties"
|
||
if [ -z "$ekeys" ]; then
|
||
echo "WARNING: $SECRETS_JSON_NAME: properties entry '$epath' has no keys. Skipping." >&2
|
||
continue
|
||
fi
|
||
;;
|
||
file)
|
||
if [ -n "$ekeys" ]; then
|
||
echo "WARNING: $SECRETS_JSON_NAME: 'file' entries take no keys ('$epath' lists '$ekeys'). Skipping." >&2
|
||
continue
|
||
fi
|
||
;;
|
||
*)
|
||
echo "WARNING: $SECRETS_JSON_NAME: unknown external type '$etype' (supported: properties file). Skipping." >&2
|
||
continue
|
||
;;
|
||
esac
|
||
case "$epath" in
|
||
''|*[!A-Za-z0-9/._~-]*|*..*)
|
||
echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in external path '$epath'. Skipping." >&2
|
||
continue
|
||
;;
|
||
esac
|
||
case "$ekeys" in
|
||
*[!A-Za-z0-9._\ -]*)
|
||
echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in key list for '$epath'. Skipping." >&2
|
||
continue
|
||
;;
|
||
esac
|
||
printf '%s\t%s\t%s\n' "$etype" "$epath" "$ekeys"
|
||
done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$manifest")
|
||
}
|
||
|
||
# External tuples for PUSH: .secrets.json entries first, then legacy
|
||
# .secrets-files entries whose (type, path) the manifest doesn't cover —
|
||
# the absorb set, which cmd_push folds into the manifest after a
|
||
# successful push so the two sources converge.
|
||
_external_entries_for_push() {
|
||
local root="$1"
|
||
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
|
||
local seen="" t p k
|
||
if [ -f "$json" ] && [ ! -L "$json" ]; then
|
||
while IFS=$'\t' read -r t p k; do
|
||
[ -n "$t" ] || continue
|
||
printf '%s\t%s\t%s\n' "$t" "$p" "$k"
|
||
seen="$seen$t|$p"$'\n'
|
||
done < <(_json_external_entries "$json")
|
||
fi
|
||
if [ -e "$legacy" ]; then
|
||
if [ -L "$legacy" ]; then
|
||
echo "WARNING: $legacy is a symlink; ignoring." >&2
|
||
elif [ -f "$legacy" ]; then
|
||
while IFS=$'\t' read -r t p k; do
|
||
[ -n "$t" ] || continue
|
||
case "$seen" in *"$t|$p"$'\n'*) continue ;; esac
|
||
printf '%s\t%s\t%s\n' "$t" "$p" "$k"
|
||
done < <(_parse_secrets_files_manifest "$legacy")
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# External tuples for PULL: the manifest wins entirely when present;
|
||
# legacy .secrets-files is only consulted in manifest-less projects.
|
||
_external_entries_for_pull() {
|
||
local root="$1"
|
||
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
|
||
if [ -f "$json" ] && [ ! -L "$json" ]; then
|
||
if [ -f "$legacy" ] && [ ! -L "$legacy" ]; then
|
||
echo "WARNING: $legacy is superseded by $SECRETS_JSON_NAME and was ignored on pull. Run 'secrets push' to absorb it, then delete it." >&2
|
||
fi
|
||
_json_external_entries "$json"
|
||
return 0
|
||
fi
|
||
[ -e "$legacy" ] || return 0
|
||
if [ -L "$legacy" ]; then
|
||
echo "WARNING: $legacy is a symlink; ignoring." >&2
|
||
return 0
|
||
fi
|
||
[ -f "$legacy" ] && _parse_secrets_files_manifest "$legacy"
|
||
return 0
|
||
}
|
||
|
||
# JSON array of legacy .secrets-files entries NOT yet in the manifest —
|
||
# what cmd_push absorbs. gradle-properties becomes 'properties' on the
|
||
# JSON side. Parser warnings suppressed (push_external_files re-parses
|
||
# and warns once).
|
||
_legacy_absorb_json() {
|
||
local root="$1"
|
||
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
|
||
local out="[]"
|
||
if [ ! -f "$legacy" ] || [ -L "$legacy" ]; then
|
||
printf '%s' "$out"
|
||
return 0
|
||
fi
|
||
local seen=""
|
||
if [ -f "$json" ] && [ ! -L "$json" ]; then
|
||
seen=$(jq -r '.external // [] | .[] | ((if .type == "properties" then "gradle-properties" else .type end) + "|" + .path)' "$json")
|
||
fi
|
||
local t p k s found jtype
|
||
while IFS=$'\t' read -r t p k; do
|
||
[ -n "$t" ] || continue
|
||
found=0
|
||
while IFS= read -r s; do [ "$s" = "$t|$p" ] && { found=1; break; }; done <<< "$seen"
|
||
[ "$found" -eq 1 ] && continue
|
||
jtype="$t"; [ "$t" = "gradle-properties" ] && jtype="properties"
|
||
out=$(printf '%s' "$out" | jq --arg type "$jtype" --arg path "$p" --arg keys "$k" \
|
||
'. + [if $type == "file" then {type: $type, path: $path}
|
||
else {type: $type, path: $path, keys: ($keys | split(" ") | map(select(length > 0)))} end]')
|
||
done < <(_parse_secrets_files_manifest "$legacy" 2>/dev/null)
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
# Quietly emit "ws-dir/basename" for every env file in a package.json
|
||
# workspace under <root>. Emits nothing (and never dies) when <root> is
|
||
# not a workspace monorepo or jq is unavailable — plain `push` calls this
|
||
# speculatively so a new workspace's env files keep getting discovered
|
||
# after the one-time --workspaces generator run (EGB-677 E13).
|
||
_maybe_workspace_env_files() {
|
||
local root="$1"
|
||
[ -f "$root/package.json" ] || return 0
|
||
command -v jq >/dev/null 2>&1 || return 0
|
||
jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 || return 0
|
||
local ws f
|
||
while IFS= read -r ws; do
|
||
[ -n "$ws" ] || continue
|
||
if collect_env_files "$root/$ws"; then
|
||
for f in "${COLLECTED_FILES[@]}"; do
|
||
printf '%s/%s\n' "$ws" "$(basename "$f")"
|
||
done
|
||
fi
|
||
done < <(get_workspaces "$root")
|
||
}
|
||
|
||
# ─── End 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
|
||
}
|
||
|
||
# Manifest-aware push (EGB-677 stage 1). Discovery (root globs + a quiet
|
||
# package.json workspace re-scan when a manifest exists) feeds the
|
||
# manifest as a GENERATOR; the sync itself runs FROM the manifest. The
|
||
# v1 store layout is unchanged: root files land at <project>/<name>.age,
|
||
# nested entries at <project>/<relpath>.age (same shape -w always used).
|
||
cmd_push() {
|
||
local frozen=false dry_run=false explicit_project=""
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--frozen) frozen=true; shift ;;
|
||
--dry-run) dry_run=true; shift ;;
|
||
-*) die "Unknown push flag: $1. Usage: secrets push [--frozen] [--dry-run] [project]" ;;
|
||
*) explicit_project="$1"; shift ;;
|
||
esac
|
||
done
|
||
|
||
check_cmd age
|
||
check_cmd git
|
||
resolve_store
|
||
check_initialized
|
||
check_key
|
||
|
||
local project
|
||
project=$(derive_project_name "$explicit_project")
|
||
info "Pushing secrets for project: $project"
|
||
echo_store_if_non_default
|
||
|
||
local pubkey
|
||
pubkey=$(get_pubkey)
|
||
|
||
# ── Manifest read (validated; absence = bootstrap) ──
|
||
# jq is required only when a manifest exists (authoritative, can't be
|
||
# ignored) or is being written. Without jq on a manifest-less project,
|
||
# manifest features are skipped with a notice — clone-and-run for v1
|
||
# users survives.
|
||
local have_jq=true
|
||
command -v jq >/dev/null 2>&1 || have_jq=false
|
||
local manifest="$PWD/$SECRETS_JSON_NAME"
|
||
local have_manifest=false auto_add=true declared=""
|
||
if [ -e "$manifest" ]; then
|
||
_check_manifest_file "$manifest"
|
||
have_manifest=true
|
||
declared=$(jq -r '.dotenv // [] | .[]' "$manifest")
|
||
local d
|
||
while IFS= read -r d; do
|
||
[ -n "$d" ] || continue
|
||
_validate_dotenv_rel_path "$d" \
|
||
|| die "Refusing unsafe dotenv path in $SECRETS_JSON_NAME (paths must be project-relative): $d"
|
||
done <<< "$declared"
|
||
# NB: jq's // treats false as empty, so `.options.autoAdd // true`
|
||
# would silently flip an explicit false back to true. Compare directly.
|
||
auto_add=$(jq -r '.options.autoAdd | if . == false then "false" else "true" end' "$manifest")
|
||
fi
|
||
[ "$frozen" = true ] && auto_add=false
|
||
|
||
# ── Discovery: root globs + workspace re-scan (manifest projects) ──
|
||
local discovered="" f
|
||
if collect_env_files "$PWD"; then
|
||
for f in "${COLLECTED_FILES[@]}"; do
|
||
discovered="$discovered$(basename "$f")"$'\n'
|
||
done
|
||
fi
|
||
if [ "$have_manifest" = true ]; then
|
||
discovered="$discovered$(_maybe_workspace_env_files "$PWD")"$'\n'
|
||
fi
|
||
|
||
# to_add = discovered − declared (deduped; pure bash 3.2, no assoc arrays)
|
||
local to_add="" e known
|
||
while IFS= read -r e; do
|
||
[ -n "$e" ] || continue
|
||
known=0
|
||
while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$declared"
|
||
[ "$known" -eq 1 ] && continue
|
||
while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$to_add"
|
||
[ "$known" -eq 1 ] && continue
|
||
to_add="$to_add$e"$'\n'
|
||
done <<< "$discovered"
|
||
|
||
if [ "$dry_run" = true ]; then
|
||
info "Dry run — nothing encrypted, nothing written."
|
||
if [ -n "$to_add" ]; then
|
||
echo "Would add to $SECRETS_JSON_NAME:"
|
||
while IFS= read -r e; do [ -n "$e" ] && echo " $e"; done <<< "$to_add"
|
||
else
|
||
echo "Nothing new to add to $SECRETS_JSON_NAME."
|
||
fi
|
||
if [ -n "$declared" ]; then
|
||
echo "Would sync (declared):"
|
||
while IFS= read -r e; do [ -n "$e" ] && echo " $e"; done <<< "$declared"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# ── Build the sync list ──
|
||
local sync_list="$declared"
|
||
if [ "$auto_add" = true ] || [ "$have_manifest" = false ]; then
|
||
sync_list="$declared"$'\n'"$to_add"
|
||
else
|
||
while IFS= read -r e; do
|
||
[ -n "$e" ] || continue
|
||
echo "WARNING: '$e' is not declared in $SECRETS_JSON_NAME and autoAdd is off — not synced. Run: secrets add $e" >&2
|
||
done <<< "$to_add"
|
||
fi
|
||
|
||
# ── Encrypt FROM the (effective) manifest ──
|
||
local count=0 rel
|
||
while IFS= read -r rel; do
|
||
[ -n "$rel" ] || continue
|
||
if [ ! -f "$PWD/$rel" ]; then
|
||
echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but not found in $PWD — skipping." >&2
|
||
continue
|
||
fi
|
||
case "$rel" in
|
||
*/*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;;
|
||
*) mkdir -p "$SECRETS_DIR/$project" ;;
|
||
esac
|
||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
|
||
echo " $rel"
|
||
count=$((count + 1))
|
||
done <<< "$sync_list"
|
||
[ "$count" -gt 0 ] && info "$project: $count file(s)"
|
||
|
||
local did=0
|
||
[ "$count" -gt 0 ] && did=1
|
||
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
|
||
|
||
# ── Manifest write AFTER successful encryption (bootstrap ordering) ──
|
||
# Two independent reasons to write: dotenv auto-adds, and absorbing a
|
||
# legacy .secrets-files (gradle-properties → properties) so the two
|
||
# external sources converge on the manifest.
|
||
if [ "$have_jq" = false ]; then
|
||
echo "NOTE: jq not found — skipping $SECRETS_JSON_NAME manifest features (auto-add, absorb). Install jq to enable them." >&2
|
||
fi
|
||
local absorbed_json="[]" n_absorbed=0
|
||
if [ "$frozen" = false ] && [ "$have_jq" = true ]; then
|
||
absorbed_json=$(_legacy_absorb_json "$PWD")
|
||
n_absorbed=$(printf '%s' "$absorbed_json" | jq 'length')
|
||
fi
|
||
local write_adds=false
|
||
if [ -n "$to_add" ] && { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then
|
||
write_adds=true
|
||
fi
|
||
if [ "$did" -eq 1 ] && [ "$frozen" = false ] && [ "$have_jq" = true ] \
|
||
&& { [ "$write_adds" = true ] || [ "$n_absorbed" -gt 0 ]; }; then
|
||
local add_json="[]"
|
||
[ "$write_adds" = true ] && add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))')
|
||
if [ "$have_manifest" = true ]; then
|
||
jq --argjson add "$add_json" --argjson ext "$absorbed_json" \
|
||
'.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \
|
||
| _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
|
||
else
|
||
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \
|
||
'{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \
|
||
| _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
|
||
fi
|
||
if [ "$write_adds" = true ]; then
|
||
while IFS= read -r e; do
|
||
[ -n "$e" ] && info "Added '$e' to $SECRETS_JSON_NAME"
|
||
done <<< "$to_add"
|
||
fi
|
||
if [ "$n_absorbed" -gt 0 ]; then
|
||
info "Absorbed $n_absorbed entr(y/ies) from $SECRETS_FILES_NAME into $SECRETS_JSON_NAME (gradle-properties → properties). $SECRETS_FILES_NAME can be deleted."
|
||
fi
|
||
info "Commit the manifest so other machines pick it up. To undo an entry: edit $SECRETS_JSON_NAME (or use 'secrets push --frozen' to skip auto-add)."
|
||
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
|
||
|
||
# ── Manifest-driven pull (EGB-677 stage 1) ──
|
||
# With a .secrets.json present, the manifest decides what restores and
|
||
# where (nested entries get their directories created). The dotenv rail
|
||
# runs again at restore time — warn+skip on pull, never die, so one bad
|
||
# entry can't block the rest of the restore.
|
||
local manifest="$PWD/$SECRETS_JSON_NAME"
|
||
if [ -e "$manifest" ]; then
|
||
_check_manifest_file "$manifest"
|
||
local declared n_external
|
||
declared=$(jq -r '.dotenv // [] | .[]' "$manifest")
|
||
n_external=$(jq -r '.external // [] | length' "$manifest")
|
||
|
||
if [ -z "$declared" ] && [ "$n_external" -eq 0 ]; then
|
||
echo "WARNING: $SECRETS_JSON_NAME declares nothing to pull (empty manifest). Run 'secrets push' on a machine that has the files." >&2
|
||
ensure_store_protections
|
||
return 0
|
||
fi
|
||
if [ -n "$declared" ] && [ ! -d "$SECRETS_DIR/$project" ]; then
|
||
die "Project '$project' not found. Run: secrets list"
|
||
fi
|
||
|
||
local count=0 rel
|
||
while IFS= read -r rel; do
|
||
[ -n "$rel" ] || continue
|
||
if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then
|
||
echo "WARNING: skipping unsafe dotenv path from $SECRETS_JSON_NAME: $rel" >&2
|
||
continue
|
||
fi
|
||
local blob="$SECRETS_DIR/$project/${rel}.age"
|
||
if [ ! -f "$blob" ]; then
|
||
echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but has no encrypted data in the store yet. Run 'secrets push' on a machine that has it. Skipping." >&2
|
||
continue
|
||
fi
|
||
case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
|
||
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"
|
||
if [ ! -s "$target_dir/$rel" ]; then
|
||
echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)"
|
||
fi
|
||
count=$((count + 1))
|
||
done <<< "$declared"
|
||
|
||
info "Decrypted $count file(s) into $target_dir"
|
||
pull_external_files "$PWD" "$project"
|
||
ensure_store_protections
|
||
return 0
|
||
fi
|
||
|
||
# ── Legacy glob pull (manifest-less projects; unchanged) ──
|
||
# 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:"
|
||
# Recurse the whole project tree so nested manifest blobs
|
||
# (<project>/<relpath>.age) are visible, not just top-level entries.
|
||
# External blobs (external/<slug>.age) are labelled distinctly.
|
||
while IFS= read -r f; do
|
||
[ -f "$f" ] || continue
|
||
local rel
|
||
rel=${f#"$dir"}
|
||
rel=${rel%.age}
|
||
case "$rel" in
|
||
external/*) echo " [external] ${rel#external/}" ;;
|
||
*) echo " $rel" ;;
|
||
esac
|
||
found=1
|
||
done < <(find "$dir" -type f -name '*.age' | sort)
|
||
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"
|
||
# Walk the WHOLE project tree, not just its top level. Manifest dotenv
|
||
# entries can nest (<project>/<relpath>.age) and external blobs live in
|
||
# <project>/external/<slug>.age. A non-recursive glob would skip both,
|
||
# leaving them encrypted under the old key = permanently undecryptable
|
||
# after rotation (silent data loss). `find` is bash-3.2 safe and recurses.
|
||
while IFS= read -r f; do
|
||
[ -f "$f" ] || continue
|
||
local rel dest
|
||
rel=${f#"$dir"} # path relative to the project dir (keeps .age)
|
||
rel=${rel%.age} # strip the .age suffix → original relpath
|
||
dest="$tmpdir/$project/$rel"
|
||
mkdir -p "$(dirname "$dest")"
|
||
if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
|
||
die "Decryption failed for $project/$rel. Rekey aborted. Old key preserved."
|
||
fi
|
||
file_count=$((file_count + 1))
|
||
done < <(find "$dir" -type f -name '*.age')
|
||
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. `find -type f` recurses into nested dotenv dirs and
|
||
# external/ and natively includes dotfiles (decrypted dotenv files like
|
||
# "$tmpdir/p/.env"), which a bare "*" glob would silently skip — leaving
|
||
# their blobs on the old key (undecryptable after rotation). The walk mirrors
|
||
# the recursive decrypt above so every blob round-trips back to its relpath.
|
||
for dir in "$tmpdir"/*/; do
|
||
[ -d "$dir" ] || continue
|
||
local project
|
||
project=$(basename "$dir")
|
||
mkdir -p "$SECRETS_DIR/$project"
|
||
while IFS= read -r f; do
|
||
[ -f "$f" ] || continue
|
||
local rel
|
||
rel=${f#"$dir"} # path relative to the project temp dir
|
||
mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
|
||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
|
||
done < <(find "$dir" -type f)
|
||
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"
|
||
|
||
# v2 manifest (.secrets.json): validate and summarize. Validation here
|
||
# is deliberately fatal (symlink / malformed / unsupported version) so
|
||
# `secrets which` doubles as the manifest linter.
|
||
local json_manifest="$PWD/$SECRETS_JSON_NAME"
|
||
if [ -e "$json_manifest" ]; then
|
||
_check_manifest_file "$json_manifest"
|
||
echo "manifest ($SECRETS_JSON_NAME at $json_manifest):"
|
||
local entry
|
||
while IFS= read -r entry; do
|
||
[ -n "$entry" ] || continue
|
||
if _validate_dotenv_rel_path "$entry"; then
|
||
echo " dotenv $entry"
|
||
else
|
||
echo " dotenv $entry [UNSAFE — will be refused]"
|
||
fi
|
||
done < <(jq -r '.dotenv // [] | .[]' "$json_manifest")
|
||
local etype epath ekeys
|
||
while IFS=$'\t' read -r etype epath ekeys; do
|
||
[ -n "$etype" ] || continue
|
||
echo " $etype $epath $ekeys"
|
||
done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$json_manifest")
|
||
fi
|
||
|
||
# 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 --frozen Sync only manifest-declared files (skip auto-add)
|
||
secrets push --dry-run Show what would be added/synced; change nothing
|
||
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 add <path> Declare a project-relative file in .secrets.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, manifest, and external entries
|
||
secrets where Alias for `which`
|
||
secrets status Alias for `which`
|
||
|
||
Tracked files: .env, .env.*, .dev.vars
|
||
|
||
Manifest (.secrets.json):
|
||
A committed project-root manifest declaring everything the project
|
||
syncs (requires jq). `secrets push` discovers conventional files and
|
||
auto-adds them with a notice; set {"options":{"autoAdd":false}} to
|
||
require explicit `secrets add` instead. Dotenv paths are project-
|
||
relative (nested workspace paths welcome); external entries use
|
||
{"type":"properties"|"file","path":...,"keys":[...]}:
|
||
|
||
{
|
||
"version": 2,
|
||
"options": { "autoAdd": true },
|
||
"dotenv": [".env", "packages/web/.env.development"],
|
||
"external": [
|
||
{ "type": "properties", "path": "~/.gradle/gradle.properties",
|
||
"keys": ["beaconClerkPkTest"] },
|
||
{ "type": "file", "path": "~/keystores/upload.keystore" }
|
||
]
|
||
}
|
||
|
||
A legacy .secrets-files is absorbed into .secrets.json on the next
|
||
push (gradle-properties entries become type "properties") and can be
|
||
deleted afterwards. Without jq, manifest-less projects keep working;
|
||
manifest features are skipped with a notice.
|
||
|
||
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 files (or specific keys from files) OUTSIDE the project root.
|
||
Create a committed .secrets-files in the project root, one entry per
|
||
line:
|
||
|
||
# <type> <path> <keys...>
|
||
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
||
file ~/keystores/beacon-upload.keystore
|
||
|
||
gradle-properties: 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.
|
||
The target basename must be 'gradle.properties'.
|
||
|
||
file: the whole file is encrypted verbatim (binary-safe — keystores,
|
||
certificates). On pull it is restored with mode 600; an existing
|
||
divergent target is backed up to <name>.secrets-bak first. No keys.
|
||
|
||
All targets must resolve inside $HOME (no '..', no symlinks). Run
|
||
'secrets which' from the project to confirm the manifest parsed.
|
||
|
||
Note: pulled external targets are permanent plaintext on disk —
|
||
'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
|
||
shift
|
||
cmd_push "$@"
|
||
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 "$@"
|
||
;;
|
||
add) cmd_add "${2:-}" ;;
|
||
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
|