Enhance secrets CLI to support additional secret file types and clear command
- Updated file tracking to include `.dev.vars` alongside `.env` and `.env.*`. - Improved pre-commit hook to block plaintext secret files, including `.dev.vars`. - Added `clear` command to remove plaintext secret files from the current directory and workspaces. - Enhanced tests to cover new functionality for `.dev.vars` and the `clear` command. - Updated documentation to reflect changes in tracked files and command usage.
This commit is contained in:
parent
585367b9a6
commit
e347e73976
5 changed files with 503 additions and 74 deletions
|
|
@ -25,9 +25,9 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
|
||||||
|
|
||||||
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
|
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
|
||||||
- Storage: Private git repo at `~/.secrets/`
|
- Storage: Private git repo at `~/.secrets/`
|
||||||
- Convention: Globs `.env` and `.env.*` (not `.envrc`, `.environment-*`)
|
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
|
||||||
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
|
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
|
||||||
- Safety: Pre-commit hook rejects plaintext `.env` files
|
- Safety: Pre-commit hook rejects plaintext secret files
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|
|
||||||
261
README.md
261
README.md
|
|
@ -1,98 +1,257 @@
|
||||||
# secrets
|
# secrets
|
||||||
|
|
||||||
Sync `.env` files between machines without storing them in git. Encrypts with [age](https://github.com/FiloSottile/age), stores in a private repo.
|
A command-line tool for sharing secret files (API keys, database passwords, tokens) between your machines and teammates — without ever putting them in your project's git history.
|
||||||
|
|
||||||
## Install
|
## The problem
|
||||||
|
|
||||||
|
Most projects have files like `.env`, `.env.staging`, or `.dev.vars` that contain sensitive credentials. These files should never be committed to your project's git repository because:
|
||||||
|
|
||||||
|
- Anyone with access to the repo can see them (even if you delete them later — git keeps history forever)
|
||||||
|
- Automated tools, CI pipelines, and compromised dependencies can read plaintext files from your project directory
|
||||||
|
- There's no safe built-in way to share these files between your laptop, your desktop, or a teammate's machine
|
||||||
|
|
||||||
|
People end up sharing secrets over Slack, email, or sticky notes. When a key changes, someone forgets to update, and things break.
|
||||||
|
|
||||||
|
## What this tool does
|
||||||
|
|
||||||
|
`secrets` encrypts your secret files and stores them in a separate, private git repository. Only someone with the encryption key can read them.
|
||||||
|
|
||||||
|
```
|
||||||
|
Your project (e.g. ~/myapp/) Your secrets store (~/.secrets/)
|
||||||
|
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ .env (plaintext)│──encrypt─▶│ myapp/.env.age (encrypted)│──sync──▶ GitHub (private)
|
||||||
|
│ .env.staging (plaintext)│ │ myapp/.env.staging.age │
|
||||||
|
│ .dev.vars (plaintext)│ │ key.txt (never uploaded) │
|
||||||
|
└──────────────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Encrypted at rest** — files are encrypted with [age](https://github.com/FiloSottile/age), a modern encryption tool. Without the key, the files are unreadable.
|
||||||
|
- **Synced via git** — the encrypted files are stored in a private git repository that syncs between machines. You never interact with this repo directly — `secrets push` and `secrets pull` handle it.
|
||||||
|
- **Minimal exposure** — `secrets run` keeps plaintext files on disk only while your command is running, then deletes them automatically.
|
||||||
|
|
||||||
|
### What files are tracked
|
||||||
|
|
||||||
|
| Pattern | Example | Source |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| `.env` | `SECRET_KEY=abc123` | Standard environment file |
|
||||||
|
| `.env.*` | `.env.staging`, `.env.production` | Environment-specific variants |
|
||||||
|
| `.dev.vars` | `CF_API_TOKEN=xyz` | Cloudflare Wrangler local secrets |
|
||||||
|
|
||||||
|
Files like `.envrc` (direnv) and `.environment-*` are intentionally **not** tracked.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **macOS** (uses Homebrew for installation)
|
||||||
|
- **git** (already installed on most Macs — type `git --version` to check)
|
||||||
|
- **age** (the encryption tool — installed in step 1 below)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### First machine (one-time setup)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Install age (encryption tool)
|
# 1. Install the encryption tool
|
||||||
brew install age
|
brew install age
|
||||||
|
|
||||||
# 2. Clone this repo (the tool's source code)
|
# 2. Download the secrets tool
|
||||||
|
# Replace <you> with your GitHub username or org
|
||||||
git clone git@github.com:<you>/secrets.git ~/dev/secrets
|
git clone git@github.com:<you>/secrets.git ~/dev/secrets
|
||||||
|
|
||||||
# 3. Add it to your PATH (e.g., in ~/.zshrc)
|
# 3. Make the 'secrets' command available everywhere
|
||||||
|
# Add this line to your shell config file (~/.zshrc on Mac):
|
||||||
export PATH="$HOME/dev/secrets:$PATH"
|
export PATH="$HOME/dev/secrets:$PATH"
|
||||||
|
# Then restart your terminal, or run:
|
||||||
|
source ~/.zshrc
|
||||||
|
|
||||||
# 4. Initialize the encrypted secrets store (separate repo)
|
# 4. Initialize your encrypted secrets store
|
||||||
|
# This creates a folder at ~/.secrets/ with your encryption key
|
||||||
secrets init
|
secrets init
|
||||||
|
|
||||||
# 5. Create a PRIVATE repo on GitHub for your encrypted secrets, then:
|
# 5. Create a PRIVATE repository on GitHub to store your encrypted secrets
|
||||||
|
# Go to github.com/new, name it something like 'my-secrets', and make sure
|
||||||
|
# "Private" is selected. Then connect it:
|
||||||
cd ~/.secrets
|
cd ~/.secrets
|
||||||
git remote add origin git@github.com:<you>/my-secrets.git
|
git remote add origin git@github.com:<you>/my-secrets.git
|
||||||
git push -u origin main
|
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.
|
> **Important:** Step 5 creates a *separate* private repo for your encrypted secrets. This is different from the `secrets` tool repo you cloned in step 2. The tool repo can be public — it contains no secrets. The `~/.secrets/` repo must be private.
|
||||||
`~/.secrets/` is the **encrypted secrets store** — a separate private git repo
|
|
||||||
where your `.env.age` files live. They are two different repos.
|
### Additional machines
|
||||||
|
|
||||||
|
On each new machine (your desktop, a teammate's laptop, etc.):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install prerequisites and the tool (same as steps 1-3 above)
|
||||||
|
brew install age
|
||||||
|
git clone git@github.com:<you>/secrets.git ~/dev/secrets
|
||||||
|
export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc
|
||||||
|
|
||||||
|
# 2. Clone the encrypted secrets repo
|
||||||
|
git clone git@github.com:<you>/my-secrets.git ~/.secrets
|
||||||
|
|
||||||
|
# 3. Copy the encryption key from your first machine
|
||||||
|
# This is the only step that requires direct machine-to-machine transfer.
|
||||||
|
# Choose one method:
|
||||||
|
#
|
||||||
|
# Option A: AirDrop (Mac to Mac)
|
||||||
|
# On your first machine, right-click ~/.secrets/key.txt → Share → AirDrop
|
||||||
|
# Save it to ~/.secrets/key.txt on the new machine
|
||||||
|
#
|
||||||
|
# Option B: Secure copy over SSH
|
||||||
|
# scp first-machine:~/.secrets/key.txt ~/.secrets/key.txt
|
||||||
|
#
|
||||||
|
# Option C: USB drive
|
||||||
|
# Copy key.txt to a USB drive, transfer it, delete from USB after
|
||||||
|
|
||||||
|
# 4. Pull your secrets into any project
|
||||||
|
cd ~/myapp
|
||||||
|
secrets pull
|
||||||
|
```
|
||||||
|
|
||||||
|
> **The key file (`~/.secrets/key.txt`) is the only thing that needs to be transferred manually.** It never leaves your machines — it's excluded from git, never uploaded, never transmitted over the internet. Anyone with this file can decrypt all your secrets, so treat it like a password.
|
||||||
|
|
||||||
|
### Sharing with teammates
|
||||||
|
|
||||||
|
To share secrets with a teammate, they need:
|
||||||
|
|
||||||
|
1. Access to your private `my-secrets` GitHub repo (add them as a collaborator)
|
||||||
|
2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person)
|
||||||
|
|
||||||
|
Everyone on the team uses the same key. When anyone runs `secrets push`, the encrypted files are updated and everyone else can `secrets pull` to get the latest version.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### Daily workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
secrets init # Create ~/.secrets repo + generate age key
|
# Start of your work session — pull the latest secrets into your project
|
||||||
secrets push [project] # Encrypt .env* files and push
|
cd ~/myapp
|
||||||
secrets pull [project] # Pull and decrypt .env* files into current dir
|
secrets pull
|
||||||
secrets push -w|--workspaces # Push .env* from all package.json workspaces
|
|
||||||
secrets pull -w|--workspaces # Pull .env* into all package.json workspaces
|
# ... code, test, deploy ...
|
||||||
secrets list # Show all projects
|
|
||||||
secrets rm <project> # Remove a project's secrets
|
# If you changed any secret files, push the updates
|
||||||
secrets rekey # Re-encrypt everything with a new key
|
secrets push
|
||||||
|
|
||||||
|
# End of session — remove plaintext secrets from disk (optional but recommended)
|
||||||
|
secrets clear
|
||||||
```
|
```
|
||||||
|
|
||||||
If `[project]` is omitted, it's derived from the current directory's git remote or name.
|
### Command reference
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `secrets init` | Create the `~/.secrets/` repo and generate an encryption key |
|
||||||
|
| `secrets push` | Encrypt secret files in the current directory and upload them |
|
||||||
|
| `secrets pull` | Download and decrypt secret files into the current directory |
|
||||||
|
| `secrets clear` | Delete plaintext secret files from the current directory |
|
||||||
|
| `secrets run <command>` | Pull secrets, run a command, then clear secrets when it exits |
|
||||||
|
| `secrets list` | Show all projects that have stored secrets |
|
||||||
|
| `secrets rm <project>` | Delete a project's secrets from the store |
|
||||||
|
| `secrets rekey` | Generate a new encryption key and re-encrypt everything |
|
||||||
|
|
||||||
|
### Automatic project detection
|
||||||
|
|
||||||
|
When you run `secrets push` or `secrets pull` without specifying a project name, the tool figures out which project you're in by:
|
||||||
|
|
||||||
|
1. Checking the current directory's git remote (e.g., `origin` → `github.com/you/myapp.git` → `myapp`)
|
||||||
|
2. Falling back to the directory name (e.g., `/Users/you/myapp` → `myapp`)
|
||||||
|
|
||||||
|
You can also specify a name explicitly: `secrets push myapp`.
|
||||||
|
|
||||||
|
### Minimizing plaintext exposure
|
||||||
|
|
||||||
|
Every second that plaintext secret files sit on disk is a window for a compromised tool or dependency to read them. `secrets run` shrinks that window to only while your command is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secrets run npm start # .env exists only while dev server is up
|
||||||
|
secrets run wrangler deploy # .dev.vars exists only during deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
When the command exits — whether normally, from an error, or from Ctrl-C — the plaintext files are automatically deleted.
|
||||||
|
|
||||||
|
This works in `package.json` scripts too, so your whole team gets the protection automatically:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "secrets run react-router dev --port 5173",
|
||||||
|
"deploy": "secrets run wrangler deploy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `npm run dev` pulls secrets, starts the dev server, and clears secrets when you stop it.
|
||||||
|
|
||||||
### Monorepo support
|
### Monorepo support
|
||||||
|
|
||||||
For monorepos with `package.json` workspaces, use `--workspaces` (`-w`) from the repo root:
|
For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
|
cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
|
||||||
secrets push -w # encrypts .env* from root + each workspace
|
secrets push -w # encrypts secrets from root + each workspace
|
||||||
secrets pull -w # decrypts into root + each workspace directory
|
secrets pull -w # decrypts into root + each workspace directory
|
||||||
|
secrets clear -w # clears secrets from root + each workspace
|
||||||
|
secrets run -w turbo dev # pull all, run command, clear all on exit
|
||||||
```
|
```
|
||||||
|
|
||||||
Secrets are stored as `<monorepo>/<workspace-path>/` in `~/.secrets/`:
|
Inside `~/.secrets/`, workspace secrets are organized by path:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.secrets/
|
~/.secrets/
|
||||||
myapp/
|
myapp/
|
||||||
.env.age # root
|
.env.age # root project secrets
|
||||||
apps/web/.env.staging.age # workspace
|
apps/web/.env.staging.age # web app workspace
|
||||||
apps/api/.env.age # workspace
|
apps/api/.env.age # api workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires `jq` (`brew install jq`).
|
Requires `jq` (`brew install jq`).
|
||||||
|
|
||||||
## How it works
|
## Safety features
|
||||||
|
|
||||||
```
|
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
|
||||||
Your project dir ~/.secrets/ (private git repo) GitHub (private)
|
- **Pre-commit hook** — a git hook in `~/.secrets/` prevents accidentally committing plaintext secret files to the encrypted store
|
||||||
┌──────────────┐ ┌────────────────────┐ ┌──────────┐
|
- **Key is never uploaded** — `key.txt` is gitignored and never leaves your machine via git
|
||||||
│ .env.staging │──age──▶ │ proj/.env.staging │──git push──▶ │ encrypted│
|
- **Encryption is file-level** — each secret file is independently encrypted. A corrupted file doesn't affect others.
|
||||||
│ .env.prod │ encrypt │ .age │ │ .age │
|
|
||||||
└──────────────┘ │ key.txt (gitignored)│ │ files │
|
|
||||||
└────────────────────┘ └──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `secrets init` generates an age key pair at `~/.secrets/key.txt`
|
## Key rotation
|
||||||
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).
|
If you suspect your key has been compromised, or a teammate leaves the team:
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
brew install bats-core
|
secrets rekey
|
||||||
bats test/secrets.bats # 25 tests
|
```
|
||||||
|
|
||||||
|
This generates a new key and re-encrypts all secrets. After rekeying:
|
||||||
|
|
||||||
|
1. Copy the new `~/.secrets/key.txt` to every machine and teammate
|
||||||
|
2. Old encrypted files remain in git history (encrypted with the old key, which should be discarded)
|
||||||
|
|
||||||
|
For complete rotation with no historical exposure, create a fresh `~/.secrets/` repo.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `SECRETS_DIR` | `~/.secrets` | Override the secrets store location |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Key file not found"** — You need `~/.secrets/key.txt`. Either run `secrets init` (first machine) or copy it from a machine that has it.
|
||||||
|
|
||||||
|
**"Not initialized"** — Run `secrets init` to create the `~/.secrets/` directory.
|
||||||
|
|
||||||
|
**"No secret files found"** — You're in a directory that doesn't have `.env`, `.env.*`, or `.dev.vars` files. Make sure you're in the right project directory.
|
||||||
|
|
||||||
|
**"Project not found"** — The project name doesn't match anything in `~/.secrets/`. Run `secrets list` to see what's stored. The name is usually derived from your directory name or git remote.
|
||||||
|
|
||||||
|
**"Fast-forward pull failed"** — Someone else pushed secrets while you had local changes. Run `secrets pull` first, then retry your push.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the test suite (37 tests)
|
||||||
|
brew install bats-core
|
||||||
|
bats test/secrets.bats
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
# Rejects staged files matching .env patterns without .age extension.
|
# Rejects staged files matching .env patterns without .age extension.
|
||||||
# This is a safety net, not a security boundary (--no-verify bypasses it).
|
# 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)
|
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true)
|
||||||
if [ -n "$BLOCKED" ]; then
|
if [ -n "$BLOCKED" ]; then
|
||||||
echo "ERROR: Plaintext env files staged for commit:"
|
echo "ERROR: Plaintext secret files staged for commit:"
|
||||||
echo "$BLOCKED"
|
echo "$BLOCKED"
|
||||||
echo "Only .age (encrypted) files should be committed."
|
echo "Only .age (encrypted) files should be committed."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
131
secrets
131
secrets
|
|
@ -46,12 +46,13 @@ derive_project_name() {
|
||||||
basename "$PWD"
|
basename "$PWD"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Collect .env and .env.* files from a directory (excluding .envrc, .environment-*)
|
# Collect secret files from a directory:
|
||||||
|
# .env, .env.*, .dev.vars (excluding .envrc, .environment-*)
|
||||||
# Sets the COLLECTED_FILES array. Returns 1 if no files found.
|
# Sets the COLLECTED_FILES array. Returns 1 if no files found.
|
||||||
collect_env_files() {
|
collect_env_files() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
COLLECTED_FILES=()
|
COLLECTED_FILES=()
|
||||||
for f in "$dir"/.env "$dir"/.env.*; do
|
for f in "$dir"/.env "$dir"/.env.* "$dir"/.dev.vars; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
local basename_f
|
local basename_f
|
||||||
basename_f=$(basename "$f")
|
basename_f=$(basename "$f")
|
||||||
|
|
@ -97,9 +98,9 @@ install_hook() {
|
||||||
# Inline hook if template not found (e.g. secrets installed standalone)
|
# Inline hook if template not found (e.g. secrets installed standalone)
|
||||||
cat > "$hook_dst" << 'HOOKEOF'
|
cat > "$hook_dst" << 'HOOKEOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
BLOCKED=$(git diff --cached --name-only | grep -E '\.env' | grep -v '\.age$' || true)
|
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true)
|
||||||
if [ -n "$BLOCKED" ]; then
|
if [ -n "$BLOCKED" ]; then
|
||||||
echo "ERROR: Plaintext env files staged for commit:"
|
echo "ERROR: Plaintext secret files staged for commit:"
|
||||||
echo "$BLOCKED"
|
echo "$BLOCKED"
|
||||||
echo "Only .age (encrypted) files should be committed."
|
echo "Only .age (encrypted) files should be committed."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -132,13 +133,15 @@ cmd_init() {
|
||||||
# Never commit the private key
|
# Never commit the private key
|
||||||
key.txt
|
key.txt
|
||||||
|
|
||||||
# Block plaintext env files
|
# Block plaintext secret files
|
||||||
**/.env
|
**/.env
|
||||||
**/.env.*
|
**/.env.*
|
||||||
|
**/.dev.vars
|
||||||
|
|
||||||
# Allow encrypted env files
|
# Allow encrypted files
|
||||||
!**/.env.age
|
!**/.env.age
|
||||||
!**/.env.*.age
|
!**/.env.*.age
|
||||||
|
!**/.dev.vars.age
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Install pre-commit hook
|
# Install pre-commit hook
|
||||||
|
|
@ -220,7 +223,7 @@ cmd_push() {
|
||||||
pubkey=$(get_pubkey)
|
pubkey=$(get_pubkey)
|
||||||
|
|
||||||
if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then
|
if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then
|
||||||
die "No .env or .env.* files found in $PWD"
|
die "No secret files (.env, .env.*, .dev.vars) found in $PWD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
commit_and_push_secrets "update $project"
|
commit_and_push_secrets "update $project"
|
||||||
|
|
@ -260,7 +263,7 @@ cmd_push_workspaces() {
|
||||||
done <<< "$workspaces"
|
done <<< "$workspaces"
|
||||||
|
|
||||||
if [ "$total" -eq 0 ]; then
|
if [ "$total" -eq 0 ]; then
|
||||||
die "No .env files found in any workspace"
|
die "No secret files found in any workspace"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
commit_and_push_secrets "update $monorepo_name workspaces"
|
commit_and_push_secrets "update $monorepo_name workspaces"
|
||||||
|
|
@ -513,27 +516,114 @@ cmd_rekey() {
|
||||||
echo "For full rotation, create a fresh repo."
|
echo "For full rotation, create a fresh repo."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd_clear() {
|
||||||
|
local dir="$PWD"
|
||||||
|
if ! collect_env_files "$dir"; then
|
||||||
|
info "No secret files to clear in $dir"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count=0
|
||||||
|
for f in "${COLLECTED_FILES[@]}"; do
|
||||||
|
rm "$f"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
info "Cleared $count secret file(s) from $dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_clear_workspaces() {
|
||||||
|
check_cmd jq
|
||||||
|
|
||||||
|
local root="$PWD"
|
||||||
|
local total=0
|
||||||
|
|
||||||
|
# Clear root
|
||||||
|
if collect_env_files "$root"; then
|
||||||
|
for f in "${COLLECTED_FILES[@]}"; do
|
||||||
|
rm "$f"
|
||||||
|
total=$((total + 1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clear each workspace
|
||||||
|
local workspaces
|
||||||
|
workspaces=$(get_workspaces "$root")
|
||||||
|
while IFS= read -r ws; do
|
||||||
|
[ -n "$ws" ] || continue
|
||||||
|
local ws_dir="$root/$ws"
|
||||||
|
if collect_env_files "$ws_dir"; then
|
||||||
|
for f in "${COLLECTED_FILES[@]}"; do
|
||||||
|
rm "$f"
|
||||||
|
total=$((total + 1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done <<< "$workspaces"
|
||||||
|
|
||||||
|
info "Cleared $total secret file(s) from workspace"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
local workspace_mode=false
|
||||||
|
local project=""
|
||||||
|
|
||||||
|
# Parse flags before the command
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-w|--workspaces) workspace_mode=true; shift ;;
|
||||||
|
--) shift; break ;;
|
||||||
|
-*) die "Unknown flag: $1. Usage: secrets run [-w] [--] <command...>" ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"
|
||||||
|
|
||||||
|
# Pull secrets
|
||||||
|
if [ "$workspace_mode" = true ]; then
|
||||||
|
cmd_pull_workspaces
|
||||||
|
else
|
||||||
|
cmd_pull "$project"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set trap to clear secrets on exit (normal, error, interrupt, terminate)
|
||||||
|
if [ "$workspace_mode" = true ]; then
|
||||||
|
trap 'cmd_clear_workspaces' EXIT
|
||||||
|
else
|
||||||
|
trap 'cmd_clear' EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute the command, capturing exit code (don't let set -e kill us)
|
||||||
|
local rc=0
|
||||||
|
"$@" || rc=$?
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_help() {
|
cmd_help() {
|
||||||
cat << 'EOF'
|
cat << 'EOF'
|
||||||
secrets — encrypted env file sync between machines
|
secrets — encrypted secret file sync between machines
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
secrets init Initialize the secrets repo and generate an age 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 [project] Encrypt secret files and push to the secrets repo
|
||||||
secrets push -w|--workspaces Push .env* from all workspaces in package.json
|
secrets push -w|--workspaces Push secrets from all workspaces in package.json
|
||||||
secrets pull [project] Pull and decrypt .env* files into current directory
|
secrets pull [project] Pull and decrypt secret files into current directory
|
||||||
secrets pull -w|--workspaces Pull .env* into all workspaces from package.json
|
secrets pull -w|--workspaces Pull secrets into all workspaces from package.json
|
||||||
|
secrets clear Remove plaintext secret files from current directory
|
||||||
|
secrets clear -w|--workspaces Clear secrets from all workspaces in package.json
|
||||||
|
secrets run [-w] <command> Pull secrets, run command, clear secrets on exit
|
||||||
secrets list List all projects and their secret files
|
secrets list List all projects and their secret files
|
||||||
secrets rm <project> Remove a project's secrets from the repo
|
secrets rm <project> Remove a project's secrets from the repo
|
||||||
secrets rekey Re-encrypt all secrets with a new key
|
secrets rekey Re-encrypt all secrets with a new key
|
||||||
|
|
||||||
|
Tracked files: .env, .env.*, .dev.vars
|
||||||
|
|
||||||
If [project] is omitted, it is derived from the current directory's
|
If [project] is omitted, it is derived from the current directory's
|
||||||
git remote (if available) or the directory name.
|
git remote (if available) or the directory name.
|
||||||
|
|
||||||
Workspaces:
|
Workspaces:
|
||||||
With -w/--workspaces, reads package.json "workspaces" field to find
|
With -w/--workspaces, reads package.json "workspaces" field to find
|
||||||
workspace directories. Each workspace's .env* files are stored under
|
workspace directories. Each workspace's secret files are stored under
|
||||||
<monorepo>/<workspace-path>/ in the secrets repo. Root .env* files
|
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
|
||||||
are stored under <monorepo>/ directly. Requires jq.
|
are stored under <monorepo>/ directly. Requires jq.
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
|
|
@ -559,6 +649,17 @@ case "${1:-help}" in
|
||||||
cmd_pull "${2:-}"
|
cmd_pull "${2:-}"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
clear)
|
||||||
|
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
|
||||||
|
cmd_clear_workspaces
|
||||||
|
else
|
||||||
|
cmd_clear
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
run)
|
||||||
|
shift
|
||||||
|
cmd_run "$@"
|
||||||
|
;;
|
||||||
list) cmd_list ;;
|
list) cmd_list ;;
|
||||||
rm) cmd_rm "${2:-}" ;;
|
rm) cmd_rm "${2:-}" ;;
|
||||||
rekey) cmd_rekey ;;
|
rekey) cmd_rekey ;;
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ load test_helper
|
||||||
|
|
||||||
run "$SECRETS_BIN" push testproj
|
run "$SECRETS_BIN" push testproj
|
||||||
[ "$status" -eq 1 ]
|
[ "$status" -eq 1 ]
|
||||||
[[ "$output" == *"No .env"* ]]
|
[[ "$output" == *"No secret files"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "push errors with missing key" {
|
@test "push errors with missing key" {
|
||||||
|
|
@ -105,6 +105,44 @@ load test_helper
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "push encrypts .dev.vars files" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars"
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" push testproj
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f "$SECRETS_DIR/testproj/.env.age" ]
|
||||||
|
[ -f "$SECRETS_DIR/testproj/.dev.vars.age" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "pull decrypts .dev.vars files" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars"
|
||||||
|
"$SECRETS_BIN" push testproj >/dev/null 2>&1
|
||||||
|
|
||||||
|
local pull_dir="$WORK_DIR/pull-devvars"
|
||||||
|
mkdir -p "$pull_dir"
|
||||||
|
cd "$pull_dir"
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" pull testproj
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(cat "$pull_dir/.dev.vars")" = "CF_SECRET=wrangler123" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "pre-commit blocks plaintext .dev.vars files" {
|
||||||
|
init_with_remote
|
||||||
|
cd "$SECRETS_DIR"
|
||||||
|
|
||||||
|
echo "LEAKED=true" > .dev.vars
|
||||||
|
git add -f .dev.vars
|
||||||
|
|
||||||
|
run git commit -m "should fail"
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"Plaintext"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
# ─── pull ──────────────────────────────────────────────────────────────
|
# ─── pull ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@test "pull decrypts files correctly" {
|
@test "pull decrypts files correctly" {
|
||||||
|
|
@ -252,6 +290,137 @@ load test_helper
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── clear ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "clear removes plaintext secret files" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars"
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
[ -f "$WORK_DIR/testproj/.env" ]
|
||||||
|
[ -f "$WORK_DIR/testproj/.env.staging" ]
|
||||||
|
[ -f "$WORK_DIR/testproj/.dev.vars" ]
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" clear
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"Cleared 3"* ]]
|
||||||
|
|
||||||
|
# Files should be gone
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.env" ]
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.env.staging" ]
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.dev.vars" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "clear does nothing when no secret files exist" {
|
||||||
|
mkdir -p "$WORK_DIR/empty"
|
||||||
|
cd "$WORK_DIR/empty"
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" clear
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"No secret files"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "clear does not remove non-secret files" {
|
||||||
|
mkdir -p "$WORK_DIR/mixed"
|
||||||
|
cd "$WORK_DIR/mixed"
|
||||||
|
echo "SECRET=yes" > .env
|
||||||
|
echo "config" > .envrc
|
||||||
|
echo "other" > app.js
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" clear
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ ! -f "$WORK_DIR/mixed/.env" ]
|
||||||
|
[ -f "$WORK_DIR/mixed/.envrc" ]
|
||||||
|
[ -f "$WORK_DIR/mixed/app.js" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "clear --workspaces removes secrets from all workspaces" {
|
||||||
|
local mono
|
||||||
|
mono=$(create_monorepo)
|
||||||
|
cd "$mono"
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
[ -f "$mono/.env" ]
|
||||||
|
[ -f "$mono/apps/web/.env.staging" ]
|
||||||
|
[ -f "$mono/apps/api/.env" ]
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" clear --workspaces
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"Cleared"* ]]
|
||||||
|
|
||||||
|
# All should be gone
|
||||||
|
[ ! -f "$mono/.env" ]
|
||||||
|
[ ! -f "$mono/apps/web/.env.staging" ]
|
||||||
|
[ ! -f "$mono/apps/api/.env" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── run ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "run pulls secrets, runs command, then clears" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
"$SECRETS_BIN" push testproj >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Remove plaintext files
|
||||||
|
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
|
||||||
|
|
||||||
|
# Run a command that reads the secret
|
||||||
|
run "$SECRETS_BIN" run cat .env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"SECRET_KEY=abc123"* ]]
|
||||||
|
|
||||||
|
# After run completes, plaintext files should be cleared
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.env" ]
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.env.staging" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "run clears secrets even if command fails" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
"$SECRETS_BIN" push testproj >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Remove plaintext files
|
||||||
|
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
|
||||||
|
|
||||||
|
# Run a command that will fail (set +e so bats captures it)
|
||||||
|
run "$SECRETS_BIN" run false
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
|
||||||
|
# Secrets should still be cleared
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.env" ]
|
||||||
|
[ ! -f "$WORK_DIR/testproj/.env.staging" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "run errors with no command" {
|
||||||
|
run "$SECRETS_BIN" run
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"Usage"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "run passes arguments through to command" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
"$SECRETS_BIN" push testproj >/dev/null 2>&1
|
||||||
|
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
|
||||||
|
|
||||||
|
# Run with multiple args
|
||||||
|
run "$SECRETS_BIN" run ls -la .env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *".env"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "run supports -- separator" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir testproj
|
||||||
|
"$SECRETS_BIN" push testproj >/dev/null 2>&1
|
||||||
|
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
|
||||||
|
|
||||||
|
run "$SECRETS_BIN" run -- cat .env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"SECRET_KEY=abc123"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
# ─── workspaces ────────────────────────────────────────────────────────
|
# ─── workspaces ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Helper: create a monorepo with package.json workspaces
|
# Helper: create a monorepo with package.json workspaces
|
||||||
|
|
@ -348,5 +517,5 @@ EOF
|
||||||
|
|
||||||
run "$SECRETS_BIN" push --workspaces
|
run "$SECRETS_BIN" push --workspaces
|
||||||
[ "$status" -eq 1 ]
|
[ "$status" -eq 1 ]
|
||||||
[[ "$output" == *"No .env files"* ]]
|
[[ "$output" == *"No secret files"* ]]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue