From 585367b9a6af75c525012508d82a839358494c38 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Mon, 23 Mar 2026 17:01:22 -0700 Subject: [PATCH] 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. --- CLAUDE.md | 3 +- README.md | 61 ++++++++-- secrets | 292 +++++++++++++++++++++++++++++++++++++--------- test/secrets.bats | 99 ++++++++++++++++ 4 files changed, 392 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b29f16..8a522e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index e5abc43..04b80ce 100644 --- a/README.md +++ b/README.md @@ -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:/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:/my-secrets.git +git push -u origin main + +# 6. Copy the key file to your other machine (one-time) +scp ~/.secrets/key.txt :~/.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 # 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 # 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 `//` 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 ``` diff --git a/secrets b/secrets index c3bc306..5046c86 100755 --- a/secrets +++ b/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 ' 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 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 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 + // in the secrets repo. Root .env* files + are stored under / 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 ;; diff --git a/test/secrets.bats b/test/secrets.bats index 3d2cb9b..1734efe 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -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"* ]] +}