# 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: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`) - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` - Safety: Pre-commit hook rejects plaintext secret files ## Project Structure ``` secrets # CLI script (~300 lines bash) hooks/pre-commit # Pre-commit hook template test/ secrets.bats # bats-core test suite (25 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. ## Multi-store resolution The active store directory is picked by `resolve_store()` using these rules, highest precedence first: 1. `--store ` flag (parsed in the main pre-pass into `STORE_OVERRIDE`). 2. `.secrets-store` file in cwd or any ancestor, walk-up bounded by `$HOME` (never reads `$HOME/.secrets-store` itself or anything above). 3. `SECRETS_DIR` env var (legacy escape hatch). 4. `~/.secrets` default. `resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read. `.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work` → `$HOME/.secrets-work`, `default` → `$HOME/.secrets`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone ` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `` placeholder. This matters because the rendered `git clone` line is meant to be copy-pasted by a teammate — without sanitization, `work evil.git;rm -rf ~` would render verbatim and execute the payload on paste. Internal flow: `_parse_secrets_store_file` returns `\t`; `_find_secrets_store_file` returns `\t\t`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`. ## Deploy Configuration - Platform: NONE (distributed via `git clone` from GitHub) - Production URL: N/A (no live service) - Release model: merge to `main` is the release. Optionally tagged with `v`. - Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL. - Staging: none. - Rollback: revert the merge commit on `main` (and delete the tag) to roll back. ## Environment variable `SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). Per-project bindings via `.secrets-store` file beat this env var; use `--store ` for one-shot overrides that beat everything.