Add support for package.json workspaces in secrets CLI

- Introduced `--workspaces` flag for `push` and `pull` commands to handle environment files in monorepos.
- Updated README and CLAUDE.md to reflect new workspace functionality and installation instructions.
- Enhanced test suite with cases for workspace operations, ensuring proper encryption and decryption of environment files.
- Improved error handling for missing package.json and workspaces field.
- Increased test coverage from 20 to 25 tests.
This commit is contained in:
Brian Majewski 2026-03-23 17:01:22 -07:00
parent 7eae4ea9a1
commit 585367b9a6
4 changed files with 392 additions and 63 deletions

292
secrets
View file

@ -46,6 +46,47 @@ derive_project_name() {
basename "$PWD"
}
# Collect .env and .env.* files from a directory (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.*; do
[ -f "$f" ] || continue
local basename_f
basename_f=$(basename "$f")
case "$basename_f" in
.envrc|.environment*) continue ;;
esac
COLLECTED_FILES+=("$f")
done
[ ${#COLLECTED_FILES[@]} -gt 0 ]
}
# Read package.json workspaces and expand globs to actual directories.
# Prints one workspace path per line (relative to the monorepo root).
get_workspaces() {
local root="$1"
local pkg="$root/package.json"
[ -f "$pkg" ] || die "No package.json found in $root"
check_cmd jq
local patterns
patterns=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null)
[ -n "$patterns" ] || die "No workspaces field in $pkg"
# Expand each glob pattern relative to root
local old_dir="$PWD"
cd "$root"
for pattern in $patterns; do
# Use bash glob expansion
for dir in $pattern; do
[ -d "$dir" ] && echo "$dir"
done
done
cd "$old_dir"
}
install_hook() {
local hook_src="$SCRIPT_DIR/hooks/pre-commit"
local hook_dst="$SECRETS_DIR/.git/hooks/pre-commit"
@ -116,6 +157,55 @@ EOF
echo " 3. Run 'secrets push <project>' from a project directory"
}
# Encrypt env files from a source dir into a project path in the secrets repo.
# Does NOT commit or push — caller handles that.
push_dir_to_project() {
local source_dir="$1"
local project="$2"
local pubkey="$3"
if ! collect_env_files "$source_dir"; then
return 1
fi
info "$project: ${#COLLECTED_FILES[@]} file(s)"
for f in "${COLLECTED_FILES[@]}"; do
echo " $(basename "$f")"
done
mkdir -p "$SECRETS_DIR/$project"
for f in "${COLLECTED_FILES[@]}"; do
local name
name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done
return 0
}
# Git commit + push for the secrets repo. Shared by push and push --workspaces.
commit_and_push_secrets() {
local message="$1"
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then
die "Fast-forward pull failed. Run 'secrets pull' first, then retry push."
fi
fi
git -C "$SECRETS_DIR" add -A
if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
info "No changes to push (secrets unchanged)"
return
fi
git -C "$SECRETS_DIR" commit -m "$message" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" push >/dev/null 2>&1
info "Pushed to remote"
else
info "Committed locally (no remote configured)"
fi
}
cmd_push() {
check_cmd age
check_cmd git
@ -126,59 +216,54 @@ cmd_push() {
project=$(derive_project_name "${1:-}")
info "Pushing secrets for project: $project"
# Glob .env and .env.* (not .envrc, .environment-*, etc.)
local files=()
for f in "$PWD"/.env "$PWD"/.env.*; do
[ -f "$f" ] || continue
local basename_f
basename_f=$(basename "$f")
# Skip patterns that aren't actual .env files
case "$basename_f" in
.envrc|.environment*) continue ;;
esac
files+=("$f")
done
if [ ${#files[@]} -eq 0 ]; then
die "No .env or .env.* files found in $PWD"
fi
info "Files to encrypt:"
for f in "${files[@]}"; do
echo " $(basename "$f")"
done
local pubkey
pubkey=$(get_pubkey)
# Encrypt each file
mkdir -p "$SECRETS_DIR/$project"
for f in "${files[@]}"; do
local name
name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done
if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then
die "No .env or .env.* files found in $PWD"
fi
# Pull before push (ff-only)
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 $project' first, then retry push."
commit_and_push_secrets "update $project"
}
cmd_push_workspaces() {
check_cmd age
check_cmd git
check_cmd jq
check_initialized
check_key
local root="$PWD"
local monorepo_name
monorepo_name=$(derive_project_name "")
info "Pushing workspaces for monorepo: $monorepo_name"
local pubkey
pubkey=$(get_pubkey)
local total=0
# Push root env files (if any)
if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then
total=$((total + ${#COLLECTED_FILES[@]}))
fi
# Push each workspace
local workspaces
workspaces=$(get_workspaces "$root")
while IFS= read -r ws; do
[ -n "$ws" ] || continue
local ws_dir="$root/$ws"
local ws_project="$monorepo_name/$ws"
if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then
total=$((total + ${#COLLECTED_FILES[@]}))
fi
done <<< "$workspaces"
if [ "$total" -eq 0 ]; then
die "No .env files found in any workspace"
fi
# Commit and push
git -C "$SECRETS_DIR" add "$project/"
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 "update $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 "Pushed $project to remote"
else
info "Committed $project locally (no remote configured)"
fi
commit_and_push_secrets "update $monorepo_name workspaces"
}
cmd_pull() {
@ -226,6 +311,85 @@ cmd_pull() {
fi
}
# Pull and decrypt .age files from a project path into a target directory.
# Does NOT do git pull — caller handles that.
pull_project_to_dir() {
local project="$1"
local target_dir="$2"
local project_dir="$SECRETS_DIR/$project"
[ -d "$project_dir" ] || return 1
local count=0
for f in "$project_dir"/*.age "$project_dir"/.*.age; do
[ -f "$f" ] || continue
local name
name=$(basename "$f" .age)
local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f"
if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)"
fi
count=$((count + 1))
done
echo "$count"
}
cmd_pull_workspaces() {
check_cmd age
check_cmd git
check_cmd jq
check_initialized
check_key
local root="$PWD"
local monorepo_name
monorepo_name=$(derive_project_name "")
info "Pulling workspaces for monorepo: $monorepo_name"
# Pull latest from remote
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
fi
local total=0
# Pull root secrets (if any)
if [ -d "$SECRETS_DIR/$monorepo_name" ]; then
local count
count=$(pull_project_to_dir "$monorepo_name" "$root")
total=$((total + count))
info "$monorepo_name (root): $count file(s)"
fi
# Pull each workspace
local workspaces
workspaces=$(get_workspaces "$root")
while IFS= read -r ws; do
[ -n "$ws" ] || continue
local ws_dir="$root/$ws"
local ws_project="$monorepo_name/$ws"
if [ -d "$SECRETS_DIR/$ws_project" ]; then
local count
count=$(pull_project_to_dir "$ws_project" "$ws_dir")
total=$((total + count))
info "$ws_project: $count file(s)"
fi
done <<< "$workspaces"
if [ "$total" -eq 0 ]; then
die "No secrets found for any workspace in $monorepo_name"
fi
info "Decrypted $total file(s) total"
# Reinstall hook if missing
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
install_hook
info "Reinstalled pre-commit hook"
fi
}
cmd_list() {
check_initialized
@ -354,16 +518,24 @@ cmd_help() {
secrets — encrypted env file sync between machines
Usage:
secrets init Initialize the secrets repo and generate an age key
secrets push [project] Encrypt .env* files and push to the secrets repo
secrets pull [project] Pull and decrypt .env* files into current directory
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 init Initialize the secrets repo and generate an age key
secrets push [project] Encrypt .env* files and push to the secrets repo
secrets push -w|--workspaces Push .env* from all workspaces in package.json
secrets pull [project] Pull and decrypt .env* files into current directory
secrets pull -w|--workspaces Pull .env* into all workspaces from package.json
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
If [project] is omitted, it is derived from the current directory's
git remote (if available) or the directory name.
Workspaces:
With -w/--workspaces, reads package.json "workspaces" field to find
workspace directories. Each workspace's .env* files are stored under
<monorepo>/<workspace-path>/ in the secrets repo. Root .env* files
are stored under <monorepo>/ directly. Requires jq.
Environment:
SECRETS_DIR Path to secrets repo (default: ~/.secrets)
EOF
@ -373,8 +545,20 @@ EOF
case "${1:-help}" in
init) cmd_init ;;
push) cmd_push "${2:-}" ;;
pull) cmd_pull "${2:-}" ;;
push)
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
cmd_push_workspaces
else
cmd_push "${2:-}"
fi
;;
pull)
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
cmd_pull_workspaces
else
cmd_pull "${2:-}"
fi
;;
list) cmd_list ;;
rm) cmd_rm "${2:-}" ;;
rekey) cmd_rekey ;;