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

View file

@ -26,6 +26,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/`
- Convention: Globs `.env` and `.env.*` (not `.envrc`, `.environment-*`)
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext `.env` files
## Project Structure
@ -34,7 +35,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
secrets # CLI script (~300 lines bash)
hooks/pre-commit # Pre-commit hook template
test/
secrets.bats # bats-core test suite (20 tests)
secrets.bats # bats-core test suite (25 tests)
test_helper.bash # Shared setup/teardown
README.md # User-facing documentation
CLAUDE.md # This file

View file

@ -5,23 +5,68 @@ Sync `.env` files between machines without storing them in git. Encrypts with [a
## Install
```bash
# 1. Install age (encryption tool)
brew install age
# Clone this repo or copy the `secrets` script to your PATH
# 2. Clone this repo (the tool's source code)
git clone git@github.com:<you>/secrets.git ~/dev/secrets
# 3. Add it to your PATH (e.g., in ~/.zshrc)
export PATH="$HOME/dev/secrets:$PATH"
# 4. Initialize the encrypted secrets store (separate repo)
secrets init
# 5. Create a PRIVATE repo on GitHub for your encrypted secrets, then:
cd ~/.secrets
git remote add origin git@github.com:<you>/my-secrets.git
git push -u origin main
# 6. Copy the key file to your other machine (one-time)
scp ~/.secrets/key.txt <other-machine>:~/.secrets/key.txt
```
This repo (`~/dev/secrets`) is the **tool** — the CLI script, tests, and docs.
`~/.secrets/` is the **encrypted secrets store** — a separate private git repo
where your `.env.age` files live. They are two different repos.
## Usage
```bash
secrets init # Create ~/.secrets repo + generate age key
secrets push [project] # Encrypt .env* files and push
secrets pull [project] # Pull and decrypt .env* files into current dir
secrets list # Show all projects
secrets rm <project> # Remove a project's secrets
secrets rekey # Re-encrypt everything with a new key
secrets init # Create ~/.secrets repo + generate age key
secrets push [project] # Encrypt .env* files and push
secrets pull [project] # Pull and decrypt .env* files into current dir
secrets push -w|--workspaces # Push .env* from all package.json workspaces
secrets pull -w|--workspaces # Pull .env* into all package.json workspaces
secrets list # Show all projects
secrets rm <project> # Remove a project's secrets
secrets rekey # Re-encrypt everything with a new key
```
If `[project]` is omitted, it's derived from the current directory's git remote or name.
### Monorepo support
For monorepos with `package.json` workspaces, use `--workspaces` (`-w`) from the repo root:
```bash
cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
secrets push -w # encrypts .env* from root + each workspace
secrets pull -w # decrypts into root + each workspace directory
```
Secrets are stored as `<monorepo>/<workspace-path>/` in `~/.secrets/`:
```
~/.secrets/
myapp/
.env.age # root
apps/web/.env.staging.age # workspace
apps/api/.env.age # workspace
```
Requires `jq` (`brew install jq`).
## How it works
```
@ -49,5 +94,5 @@ The key file must be copied to each machine once (AirDrop, scp, USB).
```bash
brew install bats-core
bats test/secrets.bats # 20 tests
bats test/secrets.bats # 25 tests
```

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 ;;

View file

@ -251,3 +251,102 @@ load test_helper
run git commit -m "should succeed"
[ "$status" -eq 0 ]
}
# ─── workspaces ────────────────────────────────────────────────────────
# Helper: create a monorepo with package.json workspaces
create_monorepo() {
local dir="$WORK_DIR/myapp"
mkdir -p "$dir/apps/web" "$dir/apps/api" "$dir/packages/auth"
cat > "$dir/package.json" << 'PKGJSON'
{
"name": "myapp",
"workspaces": ["apps/*", "packages/*"]
}
PKGJSON
# Root env
echo "ROOT_SECRET=top" > "$dir/.env"
# Workspace envs
echo "WEB_DB=webdb" > "$dir/apps/web/.env.staging"
echo "API_KEY=abc" > "$dir/apps/api/.env"
# packages/auth has no .env — should be skipped silently
# Init a git repo so derive_project_name can use dirname
git init "$dir" >/dev/null 2>&1
echo "$dir"
}
@test "push --workspaces encrypts root and workspace env files" {
init_with_remote
local mono
mono=$(create_monorepo)
cd "$mono"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 0 ]
# Root env
[ -f "$SECRETS_DIR/myapp/.env.age" ]
# Workspace envs
[ -f "$SECRETS_DIR/myapp/apps/web/.env.staging.age" ]
[ -f "$SECRETS_DIR/myapp/apps/api/.env.age" ]
# packages/auth should NOT have a dir (no .env files)
[ ! -d "$SECRETS_DIR/myapp/packages/auth" ]
}
@test "pull --workspaces decrypts into correct directories" {
init_with_remote
local mono
mono=$(create_monorepo)
cd "$mono"
"$SECRETS_BIN" push --workspaces >/dev/null 2>&1
# Remove the original env files
rm "$mono/.env" "$mono/apps/web/.env.staging" "$mono/apps/api/.env"
run "$SECRETS_BIN" pull --workspaces
[ "$status" -eq 0 ]
# Verify decrypted into correct locations
[ "$(cat "$mono/.env")" = "ROOT_SECRET=top" ]
[ "$(cat "$mono/apps/web/.env.staging")" = "WEB_DB=webdb" ]
[ "$(cat "$mono/apps/api/.env")" = "API_KEY=abc" ]
}
@test "push --workspaces errors without package.json" {
init_with_remote
mkdir -p "$WORK_DIR/nopkg"
cd "$WORK_DIR/nopkg"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No package.json"* ]]
}
@test "push --workspaces errors without workspaces field" {
init_with_remote
mkdir -p "$WORK_DIR/nows"
echo '{"name": "nows"}' > "$WORK_DIR/nows/package.json"
cd "$WORK_DIR/nows"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No workspaces"* ]]
}
@test "push --workspaces errors when no env files anywhere" {
init_with_remote
local dir="$WORK_DIR/empty-mono"
mkdir -p "$dir/apps/web" "$dir/packages/lib"
cat > "$dir/package.json" << 'EOF'
{"workspaces": ["apps/*", "packages/*"]}
EOF
git init "$dir" >/dev/null 2>&1
cd "$dir"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No .env files"* ]]
}