initial commit
This commit is contained in:
commit
7eae4ea9a1
7 changed files with 808 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
|
|
@ -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).
|
||||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -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 <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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
12
hooks/pre-commit
Executable file
12
hooks/pre-commit
Executable file
|
|
@ -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
|
||||||
383
secrets
Executable file
383
secrets
Executable file
|
|
@ -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 <url>"
|
||||||
|
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
|
||||||
|
echo " 3. Run 'secrets push <project>' 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 <project>' to add one."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_rm() {
|
||||||
|
check_cmd git
|
||||||
|
check_initialized
|
||||||
|
|
||||||
|
local project="${1:-}"
|
||||||
|
[ -n "$project" ] || die "Usage: secrets rm <project>"
|
||||||
|
|
||||||
|
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 <other-machine>:$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 <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.
|
||||||
|
|
||||||
|
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
|
||||||
253
test/secrets.bats
Normal file
253
test/secrets.bats
Normal file
|
|
@ -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 ]
|
||||||
|
}
|
||||||
52
test/test_helper.bash
Normal file
52
test/test_helper.bash
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue