No description
Find a file
Brian Majewski 7e6ddf3a12
v0.1.0.0 feat: multi-store support (EGB-281) (#1)
* 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>
2026-05-09 14:30:28 -07:00
hooks Enhance secrets CLI to support additional secret file types and clear command 2026-03-24 06:58:45 -07:00
test v0.1.0.0 feat: multi-store support (EGB-281) (#1) 2026-05-09 14:30:28 -07:00
.gitignore initial commit 2026-03-23 16:49:04 -07:00
CHANGELOG.md v0.1.0.0 feat: multi-store support (EGB-281) (#1) 2026-05-09 14:30:28 -07:00
CLAUDE.md v0.1.0.0 feat: multi-store support (EGB-281) (#1) 2026-05-09 14:30:28 -07:00
LICENSE.md Refactor secrets CLI to improve error handling and add logging features 2026-03-30 07:12:21 -07:00
README.md v0.1.0.0 feat: multi-store support (EGB-281) (#1) 2026-05-09 14:30:28 -07:00
secrets v0.1.0.0 feat: multi-store support (EGB-281) (#1) 2026-05-09 14:30:28 -07:00
VERSION v0.1.0.0 feat: multi-store support (EGB-281) (#1) 2026-05-09 14:30:28 -07:00

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.

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, 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 exposuresecrets 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)

# 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.):

# 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

# 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., origingithub.com/you/myapp.gitmyapp)
  2. Falling back to the directory name (e.g., /Users/you/myappmyapp)

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.

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

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

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:

{
  "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

# 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:

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

# 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:

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:

~/.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 uploadedkey.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:

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

# Run the test suite (37 tests)
brew install bats-core
bats test/secrets.bats