commit 7eae4ea9a156bd0395bbf34f3755b1b67acca7f6 Author: Brian Majewski Date: Mon Mar 23 16:49:04 2026 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3dcc88e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# OS +.DS_Store + +# Editor +*.swp +*~ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4b29f16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# secrets + +Encrypted env file sync between machines using `age` key-file encryption + a private git repo. + +## Quick Start + +```bash +brew install age +./secrets init # Create ~/.secrets repo + generate age key +cd ~/my-project && ./secrets push # Encrypt .env* files, commit, push +# On other machine: +cd ~/my-project && ./secrets pull # Pull + decrypt .env* files +``` + +## Testing + +```bash +brew install bats-core +bats test/secrets.bats +``` + +## Architecture + +Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey. + +- 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-*`) +- Safety: Pre-commit hook rejects plaintext `.env` files + +## Project Structure + +``` +secrets # CLI script (~300 lines bash) +hooks/pre-commit # Pre-commit hook template +test/ + secrets.bats # bats-core test suite (20 tests) + test_helper.bash # Shared setup/teardown +README.md # User-facing documentation +CLAUDE.md # This file +``` + +## Key file + +`~/.secrets/key.txt` is the age identity (private key). It is gitignored and must be copied manually to each machine once. + +## Environment variable + +`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5abc43 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# secrets + +Sync `.env` files between machines without storing them in git. Encrypts with [age](https://github.com/FiloSottile/age), stores in a private repo. + +## Install + +```bash +brew install age +# Clone this repo or copy the `secrets` script to your PATH +``` + +## 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 +``` + +If `[project]` is omitted, it's derived from the current directory's git remote or name. + +## How it works + +``` +Your project dir ~/.secrets/ (private git repo) GitHub (private) +┌──────────────┐ ┌────────────────────┐ ┌──────────┐ +│ .env.staging │──age──▶ │ proj/.env.staging │──git push──▶ │ encrypted│ +│ .env.prod │ encrypt │ .age │ │ .age │ +└──────────────┘ │ key.txt (gitignored)│ │ files │ + └────────────────────┘ └──────────┘ +``` + +1. `secrets init` generates an age key pair at `~/.secrets/key.txt` +2. `secrets push` encrypts `.env` and `.env.*` files, commits to the secrets repo, pushes +3. On your other machine: `secrets pull` fetches and decrypts into the current directory + +The key file must be copied to each machine once (AirDrop, scp, USB). + +## Safety + +- A pre-commit hook in `~/.secrets/` rejects any plaintext `.env` file +- `.gitignore` blocks `key.txt` and plaintext env files from being committed +- Only `.env` and `.env.*` files are matched (not `.envrc`, `.environment-*`, etc.) + +## Testing + +```bash +brew install bats-core +bats test/secrets.bats # 20 tests +``` diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..b8ba9d9 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Pre-commit hook for the secrets repo. +# Rejects staged files matching .env patterns without .age extension. +# This is a safety net, not a security boundary (--no-verify bypasses it). + +BLOCKED=$(git diff --cached --name-only | grep -E '\.env' | grep -v '\.age$' || true) +if [ -n "$BLOCKED" ]; then + echo "ERROR: Plaintext env files staged for commit:" + echo "$BLOCKED" + echo "Only .age (encrypted) files should be committed." + exit 1 +fi diff --git a/secrets b/secrets new file mode 100755 index 0000000..c3bc306 --- /dev/null +++ b/secrets @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +set -euo pipefail + +# secrets — encrypted env file sync between machines +# Uses age key-file encryption + a private git repo. + +SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}" +KEY_FILE="$SECRETS_DIR/key.txt" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ─── Helpers ─────────────────────────────────────────────────────────── + +die() { echo "ERROR: $*" >&2; exit 1; } +info() { echo "==> $*"; } + +check_cmd() { + command -v "$1" >/dev/null 2>&1 || die "'$1' is not installed. Run: brew install $1" +} + +check_initialized() { + [ -d "$SECRETS_DIR/.git" ] || die "Not initialized. Run: secrets init" +} + +check_key() { + [ -f "$KEY_FILE" ] || 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" +} + +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' | grep -v '\.age$' || true) +if [ -n "$BLOCKED" ]; then + echo "ERROR: Plaintext env files staged for commit:" + echo "$BLOCKED" + echo "Only .age (encrypted) files should be committed." + exit 1 +fi +HOOKEOF + chmod +x "$hook_dst" + fi +} + +# ─── Subcommands ─────────────────────────────────────────────────────── + +cmd_init() { + check_cmd age + check_cmd git + + if [ -d "$SECRETS_DIR/.git" ]; then + die "Already initialized at $SECRETS_DIR. Key file preserved." + 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 + cat > "$SECRETS_DIR/.gitignore" << 'EOF' +# Never commit the private key +key.txt + +# Block plaintext env files +**/.env +**/.env.* + +# Allow encrypted env files +!**/.env.age +!**/.env.*.age +EOF + + # 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 " + echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" + echo " 3. Run 'secrets push ' from a project directory" +} + +cmd_push() { + check_cmd age + check_cmd git + check_initialized + check_key + + local project + 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 + + # 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." + fi + 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 +} + +cmd_pull() { + check_cmd age + check_cmd git + check_initialized + check_key + + local project + project=$(derive_project_name "${1:-}") + local target_dir="$PWD" + info "Pulling secrets for project: $project" + + # 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 + + # 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" + + # 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 + + local found=0 + for dir in "$SECRETS_DIR"/*/; do + [ -d "$dir" ] || continue + local project + project=$(basename "$dir") + # Skip hidden dirs + [[ "$project" == .* ]] && continue + echo "$project:" + for f in "$dir"*.age "$dir".*.age; do + [ -f "$f" ] || continue + echo " $(basename "$f" .age)" + found=1 + done + done + + if [ "$found" -eq 0 ]; then + echo "No projects found. Run 'secrets push ' to add one." + fi +} + +cmd_rm() { + check_cmd git + check_initialized + + local project="${1:-}" + [ -n "$project" ] || die "Usage: secrets rm " + + 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 + check_initialized + check_key + + # Create temp dir with cleanup trap + local tmpdir + tmpdir=$(mktemp -d) + 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" + for f in "$dir"*.age "$dir".*.age; do + [ -f "$f" ] || continue + local name + name=$(basename "$f" .age) + if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/$name" "$f"; then + die "Decryption failed for $project/$name. Rekey aborted. Old key preserved." + fi + file_count=$((file_count + 1)) + done + 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 new key (overwrites old) + age-keygen -o "$KEY_FILE" 2>&1 + local pubkey + pubkey=$(get_pubkey) + + info "Re-encrypting all files with new key..." + + # Re-encrypt all files + for dir in "$tmpdir"/*/; do + [ -d "$dir" ] || continue + local project + project=$(basename "$dir") + mkdir -p "$SECRETS_DIR/$project" + for f in "$dir"*; do + [ -f "$f" ] || continue + local name + name=$(basename "$f") + age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" + done + done + + # Commit and push + 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 :$KEY_FILE" + echo "" + echo "WARNING: Old ciphertext remains in git history." + echo "For full rotation, create a fresh repo." +} + +cmd_help() { + cat << 'EOF' +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 + +If [project] is omitted, it is derived from the current directory's +git remote (if available) or the directory name. + +Environment: + SECRETS_DIR Path to secrets repo (default: ~/.secrets) +EOF +} + +# ─── Main ────────────────────────────────────────────────────────────── + +case "${1:-help}" in + init) cmd_init ;; + push) cmd_push "${2:-}" ;; + pull) cmd_pull "${2:-}" ;; + list) cmd_list ;; + rm) cmd_rm "${2:-}" ;; + rekey) cmd_rekey ;; + help|--help|-h) cmd_help ;; + *) die "Unknown command: $1. Run 'secrets help' for usage." ;; +esac diff --git a/test/secrets.bats b/test/secrets.bats new file mode 100644 index 0000000..3d2cb9b --- /dev/null +++ b/test/secrets.bats @@ -0,0 +1,253 @@ +#!/usr/bin/env bats + +load test_helper + +# ─── init ────────────────────────────────────────────────────────────── + +@test "init creates repo with key and gitignore" { + run "$SECRETS_BIN" init + [ "$status" -eq 0 ] + [ -d "$SECRETS_DIR/.git" ] + [ -f "$SECRETS_DIR/key.txt" ] + [ -f "$SECRETS_DIR/.gitignore" ] + grep -q "key.txt" "$SECRETS_DIR/.gitignore" + grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore" +} + +@test "init installs pre-commit hook" { + run "$SECRETS_BIN" init + [ "$status" -eq 0 ] + [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] +} + +@test "init warns if already initialized" { + "$SECRETS_BIN" init >/dev/null 2>&1 + local key_before + key_before=$(cat "$SECRETS_DIR/key.txt") + + run "$SECRETS_BIN" init + [ "$status" -eq 1 ] + [[ "$output" == *"Already initialized"* ]] + + # Key must not be overwritten + local key_after + key_after=$(cat "$SECRETS_DIR/key.txt") + [ "$key_before" = "$key_after" ] +} + +@test "init fails without age" { + # Create a temp PATH without age + local fake_path="$TEST_TMPDIR/fake-bin" + mkdir -p "$fake_path" + ln -s "$(which git)" "$fake_path/git" + ln -s "$(which bash)" "$fake_path/bash" + ln -s "$(which mkdir)" "$fake_path/mkdir" + ln -s "$(which cat)" "$fake_path/cat" + ln -s "$(which chmod)" "$fake_path/chmod" + ln -s "$(which cp)" "$fake_path/cp" + ln -s "$(which basename)" "$fake_path/basename" + ln -s "$(which dirname)" "$fake_path/dirname" + ln -s "$(which cd)" "$fake_path/cd" 2>/dev/null || true + + run env PATH="$fake_path" "$SECRETS_BIN" init + [ "$status" -eq 1 ] + [[ "$output" == *"age"* ]] +} + +# ─── push ────────────────────────────────────────────────────────────── + +@test "push encrypts .env files" { + init_with_remote + create_project_dir testproj + + run "$SECRETS_BIN" push testproj + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/testproj/.env.age" ] + [ -f "$SECRETS_DIR/testproj/.env.staging.age" ] +} + +@test "push errors with no .env files" { + init_with_remote + mkdir -p "$WORK_DIR/empty" + cd "$WORK_DIR/empty" + + run "$SECRETS_BIN" push testproj + [ "$status" -eq 1 ] + [[ "$output" == *"No .env"* ]] +} + +@test "push errors with missing key" { + init_with_remote + create_project_dir testproj + rm "$SECRETS_DIR/key.txt" + + run "$SECRETS_BIN" push testproj + [ "$status" -eq 1 ] + [[ "$output" == *"Key file"* ]] +} + +@test "push derives project name from dirname" { + init_with_remote + create_project_dir myproject + # Don't pass explicit project name + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -d "$SECRETS_DIR/myproject" ] +} + +@test "push succeeds on repeated push (age is non-deterministic)" { + init_with_remote + create_project_dir testproj + + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + # Push again — age produces different ciphertext each time, so this creates a new commit + run "$SECRETS_BIN" push testproj + [ "$status" -eq 0 ] +} + +# ─── pull ────────────────────────────────────────────────────────────── + +@test "pull decrypts files correctly" { + init_with_remote + create_project_dir testproj + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + + # Pull into a different directory + local pull_dir="$WORK_DIR/pull-target" + mkdir -p "$pull_dir" + cd "$pull_dir" + + run "$SECRETS_BIN" pull testproj + [ "$status" -eq 0 ] + [ -f "$pull_dir/.env" ] + [ -f "$pull_dir/.env.staging" ] + [ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ] + [ "$(cat "$pull_dir/.env.staging")" = "DB_HOST=staging.db.example.com" ] +} + +@test "pull errors for nonexistent project" { + init_with_remote + + run "$SECRETS_BIN" pull nonexistent + [ "$status" -eq 1 ] + [[ "$output" == *"not found"* ]] +} + +@test "pull errors with missing key" { + init_with_remote + create_project_dir testproj + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + rm "$SECRETS_DIR/key.txt" + + local pull_dir="$WORK_DIR/pull-target" + mkdir -p "$pull_dir" + cd "$pull_dir" + + run "$SECRETS_BIN" pull testproj + [ "$status" -eq 1 ] + [[ "$output" == *"Key file"* ]] +} + +@test "pull overwrites existing files" { + init_with_remote + create_project_dir testproj + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + + local pull_dir="$WORK_DIR/pull-target" + mkdir -p "$pull_dir" + echo "OLD_VALUE=stale" > "$pull_dir/.env" + cd "$pull_dir" + + run "$SECRETS_BIN" pull testproj + [ "$status" -eq 0 ] + [ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ] +} + +@test "pull reinstalls missing pre-commit hook" { + init_with_remote + create_project_dir testproj + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + + # Remove the hook + rm -f "$SECRETS_DIR/.git/hooks/pre-commit" + [ ! -f "$SECRETS_DIR/.git/hooks/pre-commit" ] + + local pull_dir="$WORK_DIR/pull-target" + mkdir -p "$pull_dir" + cd "$pull_dir" + + run "$SECRETS_BIN" pull testproj + [ "$status" -eq 0 ] + [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] + [[ "$output" == *"Reinstalled"* ]] +} + +# ─── list ────────────────────────────────────────────────────────────── + +@test "list shows projects and files" { + init_with_remote + create_project_dir projA + "$SECRETS_BIN" push projA >/dev/null 2>&1 + create_project_dir projB + "$SECRETS_BIN" push projB >/dev/null 2>&1 + + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" == *"projA"* ]] + [[ "$output" == *"projB"* ]] +} + +@test "list shows empty message" { + "$SECRETS_BIN" init >/dev/null 2>&1 + + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" == *"No projects"* ]] +} + +# ─── rm ──────────────────────────────────────────────────────────────── + +@test "rm removes project from repo" { + init_with_remote + create_project_dir testproj + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + [ -d "$SECRETS_DIR/testproj" ] + + run "$SECRETS_BIN" rm testproj + [ "$status" -eq 0 ] + [ ! -d "$SECRETS_DIR/testproj" ] +} + +@test "rm errors for nonexistent project" { + init_with_remote + + run "$SECRETS_BIN" rm nonexistent + [ "$status" -eq 1 ] + [[ "$output" == *"not found"* ]] +} + +# ─── pre-commit hook ────────────────────────────────────────────────── + +@test "pre-commit blocks plaintext env files" { + init_with_remote + cd "$SECRETS_DIR" + + echo "LEAKED=true" > .env.test + git add -f .env.test + + run git commit -m "should fail" + [ "$status" -eq 1 ] + [[ "$output" == *"Plaintext"* ]] +} + +@test "pre-commit allows .age files" { + init_with_remote + cd "$SECRETS_DIR" + + mkdir -p testproj + echo "encrypted-blob" > testproj/.env.test.age + git add testproj/.env.test.age + + run git commit -m "should succeed" + [ "$status" -eq 0 ] +} diff --git a/test/test_helper.bash b/test/test_helper.bash new file mode 100644 index 0000000..4da4265 --- /dev/null +++ b/test/test_helper.bash @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Shared setup/teardown for secrets bats tests. +# Creates isolated temp directories for each test — no side effects. + +SECRETS_BIN="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)/secrets" + +setup() { + # Check age is available + if ! command -v age >/dev/null 2>&1; then + skip "age is not installed" + fi + + # Create isolated temp environment + export TEST_TMPDIR + TEST_TMPDIR=$(mktemp -d) + + # Secrets repo lives in temp + export SECRETS_DIR="$TEST_TMPDIR/secrets-repo" + + # Working directory for simulating project dirs + export WORK_DIR="$TEST_TMPDIR/work" + mkdir -p "$WORK_DIR" + + # Create a bare "remote" repo for push/pull testing + export REMOTE_DIR="$TEST_TMPDIR/remote.git" + git init --bare "$REMOTE_DIR" >/dev/null 2>&1 +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +# Helper: initialize secrets and add remote +init_with_remote() { + run "$SECRETS_BIN" init + cd "$SECRETS_DIR" + git remote add origin "$REMOTE_DIR" + # Initial commit so push works + git commit --allow-empty -m "init" >/dev/null 2>&1 + git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1 + cd - +} + +# Helper: create .env files in a temp project dir and cd into it +create_project_dir() { + local name="${1:-testproj}" + local dir="$WORK_DIR/$name" + mkdir -p "$dir" + echo "SECRET_KEY=abc123" > "$dir/.env" + echo "DB_HOST=staging.db.example.com" > "$dir/.env.staging" + cd "$dir" +}