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:
parent
7eae4ea9a1
commit
585367b9a6
4 changed files with 392 additions and 63 deletions
292
secrets
292
secrets
|
|
@ -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 ;;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue