* feat: multi-store support via .secrets-store + --store flag Layer four-rule store resolution on top of the existing SECRETS_DIR primitive so users can manage multiple isolated encrypted stores (work vs personal, per-client, etc.) without giving up the tool's small-bash-script pitch. Resolution order (highest first): 1. --store <dir> flag (parsed in main pre-pass) 2. .secrets-store file in cwd or any ancestor up to $HOME 3. SECRETS_DIR env var (legacy escape hatch) 4. ~/.secrets default resolve_store() updates both SECRETS_DIR and KEY_FILE so existing single-store codepaths just work. New cmd_which / where / status report the active store. cmd_init, push, pull, push_workspaces, pull_workspaces, list, rm, rekey, run, which all call resolve_store at entry. Hardening from the EGB-281 adversarial review: - F1: cmd_run EXIT trap is now a named function (not string-interpolated), so paths with apostrophes still get plaintext cleaned up - F2: symlinked .secrets-store files are skipped, never read - F3/F4: --store flag rejects flag-shaped values and empty --store= - F5: HOME unset is detected up-front with a directed error - F11: check_initialized / check_key give context-aware errors that name both recovery paths (git clone vs secrets init) when a teammate clones a project bound to a non-existent store on their machine Tests: 37 → 66 (29 new). HOME=\$TEST_TMPDIR added to test setup so the walk-up logic stays bounded inside fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add Multiple stores section to README Five subsections walk users through: how store resolution works, how to set up a second store on a machine, how to bind a project, how teammates join a bound project, and how to undo or change a binding. SECRETS_DIR table entry now points readers at the new --store flag and .secrets-store file as the preferred mechanisms. * chore: bump version and changelog (v0.1.0.0) First formal release. EGB-281 adds multi-store support; this commit seeds the VERSION file (4-digit MAJOR.MINOR.PATCH.MICRO) and the CHANGELOG.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
Markdown
385 lines
15 KiB
Markdown
# secrets
|
|
|
|
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.
|
|
|
|
## 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.
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
subgraph project["Your project (~/myapp/)"]
|
|
direction TB
|
|
p1[".env (plaintext)"]
|
|
p2[".env.staging (plaintext)"]
|
|
p3[".dev.vars (plaintext)"]
|
|
end
|
|
|
|
subgraph store["Your secrets store (~/.secrets/)"]
|
|
direction TB
|
|
s1["myapp/.env.age (encrypted)"]
|
|
s2["myapp/.env.staging.age"]
|
|
k["key.txt (never uploaded)"]
|
|
end
|
|
|
|
gh["GitHub (private)"]
|
|
|
|
project -->|secrets push<br/>encrypt| store
|
|
store -->|git push| gh
|
|
gh -->|git pull| store
|
|
store -->|secrets pull<br/>decrypt| project
|
|
```
|
|
|
|
- **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
|
|
# 1. Install the encryption tool
|
|
brew install age
|
|
|
|
# 2. Download the secrets tool (this repo — contains only the CLI, no secret files)
|
|
git clone https://github.com/bmajewski/secrets.git ~/dev/secrets
|
|
|
|
# 3. Make the 'secrets' command available everywhere
|
|
# Add this line to your shell config file (~/.zshrc on Mac):
|
|
export PATH="$HOME/dev/secrets:$PATH"
|
|
# Then restart your terminal, or run:
|
|
source ~/.zshrc
|
|
|
|
# 4. Initialize your encrypted secrets store
|
|
# This creates a folder at ~/.secrets/ with your encryption key
|
|
secrets init
|
|
|
|
# 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
|
|
git remote add origin git@github.com:<you>/my-secrets.git
|
|
git push -u origin main
|
|
```
|
|
|
|
> **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.
|
|
|
|
### 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 https://github.com/bmajewski/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
|
|
|
|
### Daily workflow
|
|
|
|
```bash
|
|
# Start of your work session — pull the latest secrets into your project
|
|
cd ~/myapp
|
|
secrets pull
|
|
|
|
# ... code, test, deploy ...
|
|
|
|
# If you changed any secret files, push the updates
|
|
secrets push
|
|
|
|
# End of session — remove plaintext secrets from disk (optional but recommended)
|
|
secrets clear
|
|
```
|
|
|
|
### 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`.
|
|
|
|
### secrets run
|
|
|
|
`secrets run` is a **pull → run → clear** pipeline: it runs `secrets pull` to decrypt the latest files into your project, executes your command, then runs `secrets clear` when that command finishes. Plaintext `.env` / `.dev.vars` files exist only while your command is running.
|
|
|
|
That matters because anything on disk can be read by other processes. `secrets run` keeps that window as small as possible—useful for dev servers, deploys, and one-off scripts.
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
subgraph run["secrets run"]
|
|
direction TB
|
|
A[secrets pull] --> B["Your command"]
|
|
B --> C[secrets clear]
|
|
end
|
|
```
|
|
|
|
`secrets clear` is hooked to **shell exit**, so it runs after **success**, **non-zero exit**, or **Ctrl-C** (SIGINT).
|
|
|
|
**Syntax**
|
|
|
|
```bash
|
|
secrets run <command> [args...]
|
|
secrets run -w <command> [args...] # monorepo: all workspaces (needs jq)
|
|
secrets run -- <command> # if the command starts with -
|
|
```
|
|
|
|
Use **`--`** when the program you are running begins with a dash so it is not parsed as a `secrets` flag.
|
|
|
|
**Examples**
|
|
|
|
```bash
|
|
secrets run npm start # .env only while the dev server runs
|
|
secrets run wrangler deploy # .dev.vars only during deploy
|
|
```
|
|
|
|
**`package.json` scripts** so the whole team gets the same behavior by default:
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"dev": "secrets run react-router dev --port 5173",
|
|
"deploy": "secrets run wrangler deploy"
|
|
}
|
|
}
|
|
```
|
|
|
|
Stopping the dev server (or any failing command) ends the process; the `EXIT` trap clears secrets afterward.
|
|
|
|
If you prefer to keep decrypted files on disk for a long editing session, use `secrets pull` and `secrets clear` manually instead.
|
|
|
|
### Multiple stores
|
|
|
|
By default, all your encrypted secrets live in one store at `~/.secrets/`. That works great if you have one set of secrets shared across machines. If you want **separate stores** — for example, work secrets isolated from personal projects, or one store per client — `secrets` supports that without any special setup.
|
|
|
|
A "store" is just a directory with its own `.git` repo, age key, and remote. You can have as many as you want.
|
|
|
|
#### How a store gets picked
|
|
|
|
When you run `secrets push` or `secrets pull`, the tool resolves the active store using the first matching rule (highest precedence first):
|
|
|
|
```
|
|
1. --store <dir> flag passed on the command line
|
|
2. .secrets-store file in the current directory or any ancestor up to $HOME
|
|
3. SECRETS_DIR environment variable (legacy escape hatch)
|
|
4. ~/.secrets default
|
|
```
|
|
|
|
Run `secrets which` from any project directory to see which rule won and which store is active. Aliases `secrets where` and `secrets status` do the same thing.
|
|
|
|
#### Set up a second store on this machine
|
|
|
|
```bash
|
|
# Create a fresh store at ~/.secrets-work with its own age key
|
|
secrets --store work init
|
|
|
|
# Connect it to a separate private GitHub repo
|
|
cd ~/.secrets-work
|
|
git remote add origin git@github.com:<you>/work-secrets.git
|
|
git push -u origin main
|
|
```
|
|
|
|
The bare name `work` expands to `$HOME/.secrets-work`. Use `secrets --store /any/abs/path init` if you want a custom location.
|
|
|
|
#### Bind a project to a non-default store
|
|
|
|
In any project directory, write a `.secrets-store` file with the store's name (or path) and commit it:
|
|
|
|
```bash
|
|
cd ~/myapp
|
|
echo work > .secrets-store
|
|
git add .secrets-store
|
|
git commit -m "use work secrets store"
|
|
```
|
|
|
|
After that, every `secrets push` / `secrets pull` from this project (or any subdirectory) automatically uses `~/.secrets-work`. Teammates who clone the project get the same binding for free — the file is in the repo.
|
|
|
|
When you push or pull from a non-default store, `secrets` echoes which one is active so you can spot mistakes immediately:
|
|
|
|
```
|
|
==> Pushing secrets for project: myapp
|
|
==> Store: /Users/you/.secrets-work (from .secrets-store file (~/myapp/.secrets-store))
|
|
```
|
|
|
|
#### Joining a teammate's bound project
|
|
|
|
If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do:
|
|
|
|
```
|
|
ERROR: Store not initialized: /Users/you/.secrets-work
|
|
Resolved from: .secrets-store file (~/myapp/.secrets-store)
|
|
This path doesn't exist on this machine yet.
|
|
|
|
If you're joining a teammate's existing store:
|
|
git clone <their-store-remote> /Users/you/.secrets-work
|
|
# then copy their key.txt to /Users/you/.secrets-work/key.txt
|
|
|
|
If you want a fresh new store at this path:
|
|
secrets --store /Users/you/.secrets-work init
|
|
```
|
|
|
|
You'll need two things from the teammate who set it up:
|
|
|
|
1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine).
|
|
2. **The age key file** (`key.txt`) — same as standing up any new machine. AirDrop, scp, or USB.
|
|
|
|
Once both are in place, `secrets pull` works.
|
|
|
|
#### Undo or change a binding
|
|
|
|
```bash
|
|
# Stop using a non-default store for this project
|
|
rm .secrets-store
|
|
git commit -am "go back to default secrets store"
|
|
|
|
# Or change which store the project is bound to
|
|
echo personal > .secrets-store
|
|
git commit -am "switch to personal secrets"
|
|
```
|
|
|
|
### Monorepo support
|
|
|
|
For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once:
|
|
|
|
```bash
|
|
cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
|
|
secrets push -w # encrypts secrets from root + each workspace
|
|
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
|
|
```
|
|
|
|
Inside `~/.secrets/`, workspace secrets are organized by path:
|
|
|
|
```shell
|
|
~/.secrets/
|
|
myapp/
|
|
.env.age # root project secrets
|
|
apps/web/.env.staging.age # web app workspace
|
|
apps/api/.env.age # api workspace
|
|
```
|
|
|
|
Requires `jq` (`brew install jq`).
|
|
|
|
## Safety features
|
|
|
|
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
|
|
- **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
|
|
- **Encryption is file-level** — each secret file is independently encrypted. A corrupted file doesn't affect others.
|
|
|
|
## Key rotation
|
|
|
|
If you suspect your key has been compromised, or a teammate leaves the team:
|
|
|
|
```bash
|
|
secrets rekey
|
|
```
|
|
|
|
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 (legacy; prefer `--store` or a `.secrets-store` file — see "Multiple stores" above) |
|
|
|
|
## 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
|
|
```
|