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

55
secrets
View file

@ -37,7 +37,18 @@ die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }
check_cmd() {
command -v "$1" >/dev/null 2>&1 || die "'$1' is not installed. Run: brew install $1"
command -v "$1" >/dev/null 2>&1 && return
# Platform-aware install hint — hardcoding brew is wrong guidance on a
# Linux box or CI runner (EGB-677 DX review).
local hint="install '$1' with your package manager"
if command -v brew >/dev/null 2>&1; then
hint="brew install $1"
elif command -v apt-get >/dev/null 2>&1; then
hint="sudo apt-get install $1"
elif command -v dnf >/dev/null 2>&1; then
hint="sudo dnf install $1"
fi
die "'$1' is not installed. Run: $hint"
}
check_initialized() {
@ -1216,6 +1227,12 @@ cmd_push() {
pubkey=$(get_pubkey)
# ── Manifest read (validated; absence = bootstrap) ──
# jq is required only when a manifest exists (authoritative, can't be
# ignored) or is being written. Without jq on a manifest-less project,
# manifest features are skipped with a notice — clone-and-run for v1
# users survives.
local have_jq=true
command -v jq >/dev/null 2>&1 || have_jq=false
local manifest="$PWD/$SECRETS_JSON_NAME"
local have_manifest=false auto_add=true declared=""
if [ -e "$manifest" ]; then
@ -1312,8 +1329,11 @@ cmd_push() {
# Two independent reasons to write: dotenv auto-adds, and absorbing a
# legacy .secrets-files (gradle-properties → properties) so the two
# external sources converge on the manifest.
if [ "$have_jq" = false ]; then
echo "NOTE: jq not found — skipping $SECRETS_JSON_NAME manifest features (auto-add, absorb). Install jq to enable them." >&2
fi
local absorbed_json="[]" n_absorbed=0
if [ "$frozen" = false ]; then
if [ "$frozen" = false ] && [ "$have_jq" = true ]; then
absorbed_json=$(_legacy_absorb_json "$PWD")
n_absorbed=$(printf '%s' "$absorbed_json" | jq 'length')
fi
@ -1321,7 +1341,7 @@ cmd_push() {
if [ -n "$to_add" ] && { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then
write_adds=true
fi
if [ "$did" -eq 1 ] && [ "$frozen" = false ] \
if [ "$did" -eq 1 ] && [ "$frozen" = false ] && [ "$have_jq" = true ] \
&& { [ "$write_adds" = true ] || [ "$n_absorbed" -gt 0 ]; }; then
local add_json="[]"
[ "$write_adds" = true ] && add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))')
@ -1905,21 +1925,48 @@ secrets — encrypted secret file sync between machines
Usage:
secrets init Initialize the secrets repo and generate an age key
secrets push [project] Encrypt secret files and push to the secrets repo
secrets push --frozen Sync only manifest-declared files (skip auto-add)
secrets push --dry-run Show what would be added/synced; change nothing
secrets push -w|--workspaces Push secrets from all workspaces in package.json
secrets pull [project] Pull and decrypt secret files into current directory
secrets pull -w|--workspaces Pull secrets into all workspaces from package.json
secrets add <path> Declare a project-relative file in .secrets.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 rm <project> Remove a project's secrets from the repo
secrets rekey Re-encrypt all secrets with a new key
secrets which Show the active store path and which rule chose it
secrets which Show the active store, manifest, and external entries
secrets where Alias for `which`
secrets status Alias for `which`
Tracked files: .env, .env.*, .dev.vars
Manifest (.secrets.json):
A committed project-root manifest declaring everything the project
syncs (requires jq). `secrets push` discovers conventional files and
auto-adds them with a notice; set {"options":{"autoAdd":false}} to
require explicit `secrets add` instead. Dotenv paths are project-
relative (nested workspace paths welcome); external entries use
{"type":"properties"|"file","path":...,"keys":[...]}:
{
"version": 2,
"options": { "autoAdd": true },
"dotenv": [".env", "packages/web/.env.development"],
"external": [
{ "type": "properties", "path": "~/.gradle/gradle.properties",
"keys": ["beaconClerkPkTest"] },
{ "type": "file", "path": "~/keystores/upload.keystore" }
]
}
A legacy .secrets-files is absorbed into .secrets.json on the next
push (gradle-properties entries become type "properties") and can be
deleted afterwards. Without jq, manifest-less projects keep working;
manifest features are skipped with a notice.
If [project] is omitted, it is derived from the current directory's
git remote (if available) or the directory name.