feat: jq gating, platform-aware install hints, stage-1 docs (EGB-677 stage 1)

- jq required only when .secrets.json exists or is being written;
  manifest-less projects run jq-free (features skipped with a notice) —
  clone-and-run for v1 users survives (no-jq fixture excludes /usr/bin,
  macOS ships jq there now)
- check_cmd: platform-aware hints (brew/apt-get/dnf/generic) instead of
  hardcoded brew — correct guidance on Linux/CI
- cmd_help: add command, push flags, manifest section with example
- README: manifest section, external files rewritten around
  .secrets.json (legacy .secrets-files documented as absorbed),
  troubleshooting entries, command table, test instructions
- CLAUDE.md: manifest architecture notes, bash-3.2 '[[ ]] || false'
  testing convention, project structure refresh
This commit is contained in:
Brian Majewski 2026-06-07 08:58:16 -07:00
parent 0049584d9b
commit 89e851278b
4 changed files with 168 additions and 22 deletions

View file

@ -160,7 +160,10 @@ secrets clear
|---------|-------------|
| `secrets init` | Create the `~/.secrets/` repo and generate an encryption key |
| `secrets push` | Encrypt secret files in the current directory and upload them |
| `secrets push --frozen` | Sync only what `.secrets.json` declares (skip auto-add) |
| `secrets push --dry-run` | Show what would be added/synced without changing anything |
| `secrets pull` | Download and decrypt secret files into the current directory |
| `secrets add <path>` | Declare a project-relative file in `.secrets.json` |
| `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 |
@ -176,6 +179,32 @@ When you run `secrets push` or `secrets pull` without specifying a project name,
You can also specify a name explicitly: `secrets push myapp`.
### The manifest (`.secrets.json`)
Every project gets a committed `.secrets.json` at its root declaring exactly what syncs — the single source of truth `push` and `pull` operate from (requires `jq`):
```json
{
"version": 2,
"options": { "autoAdd": true },
"dotenv": [".env", ".env.staging", "packages/web/.env.development"],
"external": [
{ "type": "properties", "path": "~/.gradle/gradle.properties",
"keys": ["beaconClerkPkTest"] },
{ "type": "file", "path": "~/keystores/upload.keystore" }
]
}
```
You rarely write it by hand:
- **Auto-add (default):** `secrets push` discovers conventional files (`.env`, `.env.*`, `.dev.vars` — plus `package.json` workspace dirs once a manifest exists) and adds them to the manifest with an `==>` notice. Commit the manifest so other machines pick it up.
- **Explicit mode:** set `"options": {"autoAdd": false}` (a committed, team-shared setting) and `push` only syncs declared entries, warning about undeclared files. `secrets add <path>` is then the only manifest writer. Per-invocation: `push --frozen` (declared-only once) and `push --dry-run` (preview).
- `dotenv` paths are project-relative — nested monorepo paths like `packages/@acme/web/.env` are welcome; `..`, absolute paths, and symlinked manifests are refused.
- On the other machine, `secrets pull` restores exactly what the committed manifest declares, creating nested directories as needed.
Projects without a manifest keep working exactly as before (and work without `jq`); the first `push` bootstraps one for you.
### 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.
@ -352,25 +381,40 @@ Requires `jq` (`brew install jq`).
Some credentials don't live in your project at all. Android builds, for example, read keys from `~/.gradle/gradle.properties` — a global file, outside any project, shared by every Gradle project on your machine (the project's own `gradle.properties` is git-tracked, so it's the wrong home for secrets). `secrets` can sync specific keys from such a file without touching the unrelated keys around them.
You declare what to sync in a committed `.secrets-files` manifest at your project root, one entry per line:
You declare what to sync in the `external` array of your committed `.secrets.json`:
```
# <type> <path> <keys...>
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
```json
{
"version": 2,
"external": [
{ "type": "properties", "path": "~/.gradle/gradle.properties",
"keys": ["beaconClerkPkTest", "beaconClerkPkLive"] }
]
}
```
- **type**`gradle-properties` (sync named keys) or `file` (sync the whole file — see below).
- **path** — absolute or `~/`-relative; must resolve inside `$HOME`. For `gradle-properties` the basename must be `gradle.properties`.
- **keys** — the property names to sync (`gradle-properties` only). Only these keys are read on push and merged on pull; everything else in the file is left alone. `file` entries take no keys.
- **type**`properties` (sync named keys from a Java-properties-style file) or `file` (sync the whole file — see below).
- **path** — absolute or `~/`-relative; must resolve inside `$HOME`. For `properties` the basename must end in `.properties`.
- **keys** — the property names to sync (`properties` only). Only these keys are read on push and merged on pull; everything else in the file is left alone. `file` entries take no keys.
> **Legacy `.secrets-files`:** older projects declared these entries in a line-based `.secrets-files`. It still parses, and the next `secrets push` absorbs its entries into `.secrets.json` (type `gradle-properties` becomes `properties`) — after that the legacy file is superseded and can be deleted.
#### Syncing to a second machine
On the machine that already has the keys set:
On the machine that already has the keys set, add the entry to `.secrets.json` (create the file if the project doesn't have one yet):
```bash
cd ~/myapp
echo "gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive" > .secrets-files
git add .secrets-files && git commit -m "sync gradle Clerk keys"
cat > .secrets.json <<'EOF'
{
"version": 2,
"external": [
{ "type": "properties", "path": "~/.gradle/gradle.properties",
"keys": ["beaconClerkPkTest", "beaconClerkPkLive"] }
]
}
EOF
git add .secrets.json && git commit -m "sync gradle Clerk keys"
secrets push
# ==> Extracted 2 key(s) from ~/.gradle/gradle.properties
```
@ -393,9 +437,8 @@ secrets pull
Some external secrets are whole binary files — an Android upload keystore, a certificate. The `file` type syncs the file verbatim (binary-safe, encrypted with age like everything else):
```
# <type> <path>
file ~/keystores/beacon-upload.keystore
```json
{ "type": "file", "path": "~/keystores/beacon-upload.keystore" }
```
On `secrets push` the file is encrypted into `<project>/external/`. On `secrets pull` it is restored to the same path with mode `600`; if a different version already exists there, it is backed up to `<name>.secrets-bak` first. The same path rules apply (inside `$HOME`, no `..`, no symlinks). Like merged Gradle keys, restored files are permanent plaintext on disk — `secrets clear` does not remove them.
@ -444,10 +487,14 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
**"Fast-forward pull failed"** — Someone else pushed secrets while you had local changes. Run `secrets pull` first, then retry your push.
**".secrets.json: invalid JSON" / "manifest version N is not supported"** — The committed manifest is malformed or written by a newer `secrets`. The error names the file; fix the syntax, or update the tool (`git pull` in the tool's clone).
**"'jq' is not installed"** — Manifest features need `jq`. The error prints the install command for your platform. Manifest-less projects work without it.
## Development
```bash
# Run the test suite (126 tests)
# Run the test suite (174 tests across both files)
brew install bats-core
bats test/secrets.bats
bats test/
```