Merge origin/main into EGB-283 (multi-recipient age encryption)

Reconcile the multi-recipient branch (cut from v0.6.1.0) with origin/main
at v0.7.4.0. The two feature lines are largely orthogonal; the one real
integration point is the external-blob encrypt path:

- EGB-712 added an additive-v2 dual-write loop (_external_blob_write_targets,
  writing v2 + any v1 twin). EGB-283 routes every encrypt site through
  RECIPIENT_ARGS for N-recipient encryption. Resolution keeps the dual-write
  loop but encrypts each target to the full recipient set
  (age "${RECIPIENT_ARGS[@]}" per write target), so dual-write and
  multi-recipient compose. cmd_push loads recipients before both external
  push sites; legacy single-key rekey keeps its fresh-keypair pubkey path.

Version: 0.6.2.0 + 0.7.4.0 -> 0.7.5.0. Docs (CLAUDE.md/README/CHANGELOG)
merged to carry both feature sets; subcommand list now includes
recipients/reencrypt and upgrade.

Tests: full `bats test/` green except 6 pre-existing host-environment
failures (4 chmod-600 restore assertions + 2 jq-PATH-shadow tests, all
macOS-authored), none touching merged code. recipients.bats 34/34 pass;
external/dual-write area passes except the same mode-600 host artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 14:52:32 -07:00
commit e29024bd63
16 changed files with 2723 additions and 142 deletions

View file

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
## [0.6.2.0] - 2026-06-24 ## [0.7.5.0] - 2026-06-24
### Added ### Added
@ -36,6 +36,139 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
(skipped on legacy stores). Exits non-zero on any count mismatch so it can (skipped on legacy stores). Exits non-zero on any count mismatch so it can
gate CI or a migration. gate CI or a migration.
## [0.7.4.0] - 2026-06-18
### Added
- **`secrets upgrade` verb (EGB-716)** — the fix path paired with the EGB-713
version-skew *warning*. Until now the warning told you you were behind but not
how to catch up; `secrets upgrade` closes that loop.
- **`secrets upgrade`** — `git -C "$SCRIPT_DIR" pull --ff-only` on the tool's
own checkout (fast-forward only — never merges or rewrites local commits),
reports `vOLD -> vNEW`, then best-effort re-checks the store's recorded
writer-version against the new version so you see whether the EGB-713 nudge
is now cleared (the new code itself takes effect on your next command).
- **`secrets upgrade --check`** — reports whether an update is available
(`git fetch` + compare to upstream) and changes nothing.
- Deliberately thin: no auto-update, no background polling (this is a security
tool). Directed errors for not-a-git-checkout, no upstream, a diverged/dirty
branch, or being offline.
## [0.7.3.1] - 2026-06-18
### Changed
- **EGB-677 stage-1 structural cleanups (EGB-701)** — tech-debt dedup with one
new safety warning; no behavior change for the manifest-driven (v2) happy path.
- **`secrets which` now reuses the one external-entry extractor** the push/pull
path uses (`_json_external_entries`) instead of its own duplicated `jq`
projection. So `which` applies the same `properties``gradle-properties`
normalization and skips (with a warning) the same malformed external entries
the sync path drops — `which` shows exactly what will sync, not a stale raw
projection that could drift from the real behavior.
- **The two external-manifest read guards are factored into shared helpers**
`_json_readable` (plain regular file, silent) and `_legacy_readable` (warns
and skips a symlinked `.secrets-files`) — so `_external_entries_for_push` and
`_external_entries_for_pull` can't drift apart.
### Fixed
- **Legacy (manifest-less) `pull` no longer silently under-restores (EGB-701)**
the manifest-less pull path globs only top-level `*.age`/`.*.age`, so a nested
dotenv blob (`<project>/<relpath>.age`) written by a manifest-driven push on
another machine was invisible: restored nothing, counted nothing, said nothing.
It now **warns** and names each nested blob it can't reach (external blobs are
excluded — `pull_external_files` handles those), pointing at committing a
`.secrets.json` as the fix. The manifest-driven pull already restored nesting
correctly; this only closes the legacy path's blind spot.
## [0.7.3.0] - 2026-06-08
### Added
- **Real install / onboarding scripts (EGB-671)** — onboarding a machine is now
(close to) one command, and a mis-copied key fails loudly instead of silently.
- **`secrets join --remote <url> --key <path>`** — second-machine onboarding in
one verb: clones the vault, installs the key at mode 600, and **verifies the
key actually decrypts the store before declaring success**. An empty vault
reports "nothing to verify yet" (it never prints a false `VERIFIED`); a wrong
key fails loudly with the store left in place to fix. All security logic
(store resolution, URL handling, path rails) is reused from the audited core,
not re-implemented in a side script.
- **`secrets init --remote <url>`** — wires the remote and pushes the initial
store so the upstream branch exists, so your first project `push` doesn't trip
the fast-forward-pull guard on a brand-new empty remote. Run interactively,
`init` also offers to add your first project's secrets (default No, skipped
under `--yes` / non-interactive, so it stays a clean primitive for CI).
- **`install.sh`** — thin bootstrap that ships in the repo: checks `age` + `jq`
+ `git`, then prints the `PATH` line, the onboarding next-steps, the upgrade
one-liner, and a key-transfer hint. It never edits your shell config and never
runs `sudo` (it prints the command so you stay in control).
- **First-manifest `options.autoAdd` prompt (EGB-677 contract #2)** — the first
`push` that scaffolds a project's manifest now records an explicit, committed
`options.autoAdd` value (asked once when interactive; the default ON, written
explicitly, under automation).
### Fixed
- **Day-2 silent decrypt failure**`secrets pull` now dies loudly when a blob
fails to decrypt with the current key (all three decrypt paths), instead of
emitting a warning and continuing with exit 0. A wrong key can no longer pass
unnoticed after onboarding.
- The `secrets init` second-machine trap now points at `secrets join` (the real
one-command path) instead of a manual `git clone`.
## [0.7.2.0] - 2026-06-08
### Added
- **`secrets list --json` (EGB-699)** — machine-readable listing for tooling and
CI. Emits a single JSON object on stdout: `{"store", "projects": [{"name",
"entries": [...]}]}`, where each entry self-describes via a `type`
discriminator — `{"type":"dotenv","path":<relpath>}` or
`{"type":"external","subtype":"properties"|"file","path":<slug>}`. Reflects the
same recursive store walk as the human `list` (nested `<project>/<relpath>.age`
+ `external/<slug>.age`). jq does the assembly so paths escape correctly; the
human store hint is suppressed so stdout stays pure JSON (notices → stderr).
jq is required only for `--json`. Feeds the EGB-671 install scripts, which need
to enumerate a cloned store programmatically instead of scraping the table.
## [0.7.1.0] - 2026-06-08
### Added
- **Version-skew nudge (EGB-713)** — the store now records the highest `secrets`
version that has written to it (`.secrets-writer-version`, committed,
monotonic). When you run a command against a store last written by a *newer*
`secrets` than your own, you get a one-line non-fatal stderr nudge to update
your tool; `secrets which` shows the store's `written-by:` version (and flags
when you're behind). Stores written by older builds carry no stamp and stay
silent — no false alarms. The loud counterpart to EGB-712's quiet
forcing function.
## [0.7.0.0] - 2026-06-08
### Changed
- **Additive store-format v2 (EGB-712)** — upgraded `secrets` clients now read
either external blob suffix (`.properties.age` or the legacy
`.gradle-properties.age`) and **dual-write** a `properties` external whenever a
v1 twin already exists in the store. Existing externals keep working for
teammates on an older `secrets`; only a brand-new `properties` external is
written v2-only (a gentle "upgrade to see it" forcing function). dotenv and
whole-`file` externals are unchanged across formats and always propagate.
- **`secrets migrate --finalize` is now optional GC**, not a required milestone.
Because clients dual-write and read-fall-back, no teammate is ever cut off by
*not* finalizing; finalize only reclaims the duplicate v1 blobs, and stays
deferrable indefinitely. Its safety gates are unchanged. This defuses the
cross-machine "all clients must be v2 before finalize" coordination gate.
### Added
- **`secrets migrate --status`** now reports `v2-only` externals per project
(the ones an un-upgraded client cannot read), so you can see the forcing
function's footprint at a glance.
## [0.6.1.0] - 2026-06-08 ## [0.6.1.0] - 2026-06-08
### Changed ### Changed

View file

@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files
```bash ```bash
brew install bats-core brew install bats-core
bats test/ # runs secrets.bats + manifest.bats + migrate.bats + recipients.bats bats test/ # runs secrets.bats + manifest.bats + migrate.bats + upgrade.bats + recipients.bats
./test/run-security.sh # security regression subset + operator sign-off (see below) ./test/run-security.sh # security regression subset + operator sign-off (see below)
``` ```
@ -56,13 +56,13 @@ skips security specialist + red team, and Step 11 skips adversarial review.
## Architecture ## Architecture
Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt. Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt, upgrade.
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable) - Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/` - Storage: Private git repo at `~/.secrets/`
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`) - Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
- Manifest (EGB-677 stage 1): committed `.secrets.json` is the source of truth for what syncs — `dotenv[]` (project-relative, nested ok, `@` allowed; rail rejects `..`/absolute/symlink) + `external[]` (`properties`/`file`). Push discovery auto-adds (gated by committed `options.autoAdd`, default ON; `--frozen`/`--dry-run` overrides), bootstraps the manifest on first push (written only after ≥1 blob encrypts), and absorbs a legacy `.secrets-files` (gradle-properties → `properties`; on pull the legacy file is superseded with a warning). Store layout: nested dotenv entries land at `<project>/<relpath>.age` (relpath preserved — the store self-describes where a file restores). jq is a hard dep only when a manifest exists/is written; manifest-less projects run jq-free (manifest features skipped with a notice). `check_cmd` prints platform-aware install hints. - Manifest (EGB-677 stage 1): committed `.secrets.json` is the source of truth for what syncs — `dotenv[]` (project-relative, nested ok, `@` allowed; rail rejects `..`/absolute/symlink) + `external[]` (`properties`/`file`). Push discovery auto-adds (gated by committed `options.autoAdd`, default ON; `--frozen`/`--dry-run` overrides), bootstraps the manifest on first push (written only after ≥1 blob encrypts), and absorbs a legacy `.secrets-files` (gradle-properties → `properties`; on pull the legacy file is superseded with a warning). Store layout: nested dotenv entries land at `<project>/<relpath>.age` (relpath preserved — the store self-describes where a file restores). jq is a hard dep only when a manifest exists/is written; manifest-less projects run jq-free (manifest features skipped with a notice). `check_cmd` prints platform-aware install hints.
- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age``.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker; `_external_blob_suffix(type)` is the single source of truth for the suffix (push/pull/verify all route through it, so v1 and v2 stores never disagree on where a blob lives). `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at <path>, version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710). The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. - Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age``.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at <path>, version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. **Version-skew nudge (EGB-713):** a committed `$SECRETS_DIR/.secrets-writer-version` records the highest client `VERSION` that has written to the store (monotonic; stamped via `_stamp_writer_version` right before each store-committing `git add -A` — push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to break `pull --ff-only`). `check_initialized` calls `_check_store_version_skew`, which warns once per invocation (stderr, non-fatal, `set -e`-safe) when the store's stamp is numerically greater than `_client_version` (read from `$SCRIPT_DIR/VERSION`); `secrets which` prints the `written-by:` line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. **Upgrade verb (EGB-716):** `secrets upgrade` is the fix path paired with the EGB-713 skew *warning* — it `git -C "$SCRIPT_DIR" pull --ff-only`s the tool's own checkout (fast-forward only, never merges/rewrites local commits), reports `vOLD -> vNEW`, then best-effort re-checks `_store_writer_version` against the new on-disk version so the operator sees whether the nudge is cleared (the new code takes effect next invocation). `secrets upgrade --check` does `git fetch` + `rev-list --count HEAD..@{u}` and reports availability without pulling. Deliberately thin: no auto-update, no background polling (security tool). Directed errors for not-a-git-checkout / no-upstream / diverged / offline. `cmd_upgrade` never calls `check_initialized` (it's about the tool, not the store); the skew re-check is silent unless a store with a writer-version resolves.
- Verify (EGB-698): `secrets verify` is a read-only integrity check. Default mode (current project) cross-checks `$PWD/.secrets.json` against `$SECRETS_DIR/<project>/` both ways (declared-but-missing blobs + orphaned blobs) and decrypt-tests every blob (dotenv + external) by streaming plaintext to `/dev/null` (never written to disk). `secrets verify --all` decrypt-tests every blob in every project (integrity only — the store carries no manifests, so consistency can't be checked store-wide). Both recurse the whole project tree (`find -type f`, same as rekey/list). Exits non-zero on any finding so it can gate the stage-2 `migrate --finalize` and CI. The store deliberately holds no manifest — `.secrets.json` is committed in each project's own repo and read from `$PWD`. - Verify (EGB-698): `secrets verify` is a read-only integrity check. Default mode (current project) cross-checks `$PWD/.secrets.json` against `$SECRETS_DIR/<project>/` both ways (declared-but-missing blobs + orphaned blobs) and decrypt-tests every blob (dotenv + external) by streaming plaintext to `/dev/null` (never written to disk). `secrets verify --all` decrypt-tests every blob in every project (integrity only — the store carries no manifests, so consistency can't be checked store-wide). Both recurse the whole project tree (`find -type f`, same as rekey/list). Exits non-zero on any finding so it can gate the stage-2 `migrate --finalize` and CI. The store deliberately holds no manifest — `.secrets.json` is committed in each project's own repo and read from `$PWD`.
- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below - External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
@ -93,10 +93,11 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
secrets # CLI script (~2000 lines bash) secrets # CLI script (~2000 lines bash)
hooks/pre-commit # Pre-commit hook template hooks/pre-commit # Pre-commit hook template
test/ test/
secrets.bats # bats-core test suite (133 tests) secrets.bats # bats-core test suite (140 tests)
manifest.bats # EGB-677 .secrets.json manifest tests (78 tests) manifest.bats # EGB-677 .secrets.json manifest tests (83 tests)
migrate.bats # EGB-703 store-format-v2 migration tests (31 tests) migrate.bats # EGB-703 store-format-v2 migration tests (35 tests)
recipients.bats # EGB-283 multi-recipient age encryption tests (30 tests) upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests)
recipients.bats # EGB-283 multi-recipient age encryption tests (34 tests)
test_helper.bash # Shared setup/teardown test_helper.bash # Shared setup/teardown
README.md # User-facing documentation README.md # User-facing documentation
CLAUDE.md # This file CLAUDE.md # This file
@ -126,7 +127,7 @@ The active store directory is picked by `resolve_store()` using these rules, hig
Key design decisions (all driven by /autoplan review): Key design decisions (all driven by /autoplan review):
- **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel). - **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel).
- **Storage:** blobs live in `$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age`. The `external/` subdir keeps them out of the legacy non-recursive `*.age` / `.*.age` globs the dotenv `pull` path uses, so a dotenv pull can never decrypt an external blob into cwd. `cmd_rekey` and `cmd_list` instead walk the **entire** project tree (`find -type f`), so they cover both `external/<slug>.age` and nested manifest dotenv blobs (`<project>/<relpath>.age`) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob"). `<slug>` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). - **Storage:** blobs live in `$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age`. The `external/` subdir keeps them out of the legacy non-recursive `*.age` / `.*.age` globs the dotenv `pull` path uses, so a dotenv pull can never decrypt an external blob into cwd. `cmd_rekey` and `cmd_list` instead walk the **entire** project tree (`find -type f`), so they cover both `external/<slug>.age` and nested manifest dotenv blobs (`<project>/<relpath>.age`) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob"). **EGB-701 cleanups:** (1) the *legacy* (manifest-less) `pull` keeps its non-recursive globs but now **warns** when nested `<project>/<relpath>.age` blobs exist that those globs can't see (it excludes `external/`, which `pull_external_files` handles) — so a manifest-less pull never silently under-restores; the fix the warning points at is committing a `.secrets.json`. (2) `cmd_which`, push, and pull share one external extractor (`_json_external_entries`), so `which` applies the same `properties``gradle-properties` normalization and skip-with-warning rules the sync path does (it shows exactly what will sync, not a stale raw projection). (3) the two external-manifest read guards are factored into `_json_readable` (plain regular file, silent) / `_legacy_readable` (warn-and-skip on a symlinked legacy manifest). `<slug>` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). `cmd_list --json` (EGB-699) emits the same recursive walk as a machine-readable object (`{store, projects[].entries[]}`, each entry `dotenv``path` or `external``subtype`+`path`) for tooling/CI (feeds EGB-671); jq assembles it so paths escape correctly and stdout stays pure JSON (the human store hint is suppressed; jq is a hard dep only in `--json` mode).
- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `<target>.secrets-bak` before each merge. - **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `<target>.secrets-bak` before each merge.
- **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins. - **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins.
- **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped). - **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped).

142
README.md
View file

@ -58,83 +58,95 @@ Beyond project files, `secrets` can also sync files that live *outside* the proj
## Prerequisites ## Prerequisites
- **macOS** (uses Homebrew for installation) - **macOS or Linux**
- **git** (already installed on most Macs — type `git --version` to check) - **git** (`git --version` to check)
- **age** (the encryption tool — installed in step 1 below) - **age** and **jq**`install.sh` checks for these and prints the exact install command for your platform (Homebrew on macOS, `apt`/`dnf` on Linux)
## Setup ## Setup
### First machine (one-time setup) Clone the tool repo, then run `install.sh`. It checks dependencies and prints the
two commands to finish setup. It never edits your shell config and never runs
sudo — it prints the commands so you stay in control.
```bash ```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://codeberg.org/egbt/secrets.git ~/dev/secrets git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets
# 3. Make the 'secrets' command available everywhere ./install.sh
# 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. `install.sh` prints a `export PATH="$HOME/dev/secrets:$PATH"` line — add it to your
shell config (`~/.zshrc` or `~/.bashrc`) and restart your terminal. Then onboard
this machine with one of the two flows below.
### Additional machines ### First machine (new vault)
On each new machine (your desktop, a teammate's laptop, etc.):
```bash ```bash
# 1. Install prerequisites and the tool (same as steps 1-3 above) # 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a
brew install age # Codeberg/GitLab private repo). It holds only ciphertext — never your key.
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets # Then wire it up and push the store in one command:
export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc secrets init --remote git@github.com:<you>/my-secrets.git
# 2. Clone the encrypted secrets repo # 2. (optional) Add a project's secrets. From a project directory:
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 cd ~/myapp
secrets pull secrets push
# The first push asks once whether to auto-track new env files and records
# your choice in the project's .secrets.json.
``` ```
`secrets init --remote` generates your key (`~/.secrets/key.txt`), wires the
remote, and pushes the initial store so the upstream branch exists. The private
secrets repo is separate from this tool repo — the tool repo is public and holds
no secrets; the `~/.secrets/` repo must be private.
> Running `secrets init` interactively (in a terminal) also offers to add your
> first project's secrets right away. Run it with `--yes` (or in any non-tty
> context like CI) to skip that prompt and just create the vault.
### Other machines (join an existing vault)
On a second machine, a desktop, or a teammate's laptop:
```bash
# 1. Clone the tool and run the bootstrap (as in Setup above)
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets && ./install.sh # add the printed PATH line to your shell config
# 2. Get key.txt onto this machine (the one manual, out-of-band step):
# AirDrop (Mac→Mac), or
# scp first-machine:~/.secrets/key.txt ~/Downloads/key.txt, or
# a USB drive (delete from the drive afterward)
# 3. Join the vault in one command:
secrets join --remote git@github.com:<you>/my-secrets.git --key ~/Downloads/key.txt
```
`secrets join` clones the vault, installs the key at mode 600, and **verifies the
key actually decrypts the store before declaring success** — a mis-copied key
fails loudly here, not silently on a later `secrets pull`. On success it tells you
to run `secrets pull` in any project.
> **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. > **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 ### Sharing with teammates
**Simple approach (shared key):** To share secrets with a teammate, they need: **Simple approach (shared key):** To share secrets with a teammate, they need:
1. Access to your private `my-secrets` GitHub repo (add them as a collaborator) 1. Access to your private secrets repo (add them as a collaborator)
2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person) 2. A copy of `key.txt` (send it 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. Everyone on the team uses the same key. A teammate joins with
`secrets join --remote <repo-url> --key <path-to-key.txt>`. When anyone runs
`secrets push`, the encrypted files update and everyone else runs `secrets pull`
to get the latest.
### Updating the tool
```bash
git -C ~/dev/secrets pull
```
If your store was last written by a newer client than yours, `secrets` prints a
one-line version-skew nudge — that's your cue to run the command above.
**Per-teammate keys (recommended for teams):** Use `secrets recipients add` so each person keeps their own private key — no key sharing needed. See [Onboarding and offboarding teammates](#onboarding-and-offboarding-teammates) below. **Per-teammate keys (recommended for teams):** Use `secrets recipients add` so each person keeps their own private key — no key sharing needed. See [Onboarding and offboarding teammates](#onboarding-and-offboarding-teammates) below.
@ -169,17 +181,35 @@ secrets clear
| `secrets clear` | Delete plaintext secret files from 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 run <command>` | Pull secrets, run a command, then clear secrets when it exits |
| `secrets list` | Show all projects that have stored secrets | | `secrets list` | Show all projects that have stored secrets |
| `secrets list --json` | Same listing as a machine-readable JSON object (`{store, projects[].entries[]}`, each entry `dotenv`/`external`) for tooling and CI. JSON goes to stdout; notices to stderr |
| `secrets rm <project>` | Delete a project's secrets from the store | | `secrets rm <project>` | Delete a project's secrets from the store |
| `secrets rekey` | Generate a new encryption key and re-encrypt everything (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) | | `secrets rekey` | Generate a new encryption key and re-encrypt everything (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) |
| `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory | | `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory |
| `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep | | `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep |
| `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) | | `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) |
| `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready | | `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready |
| `secrets migrate --finalize` | Drop the old v1 blobs and mark the store v2 — runs once, store-wide, after `verify` is green and every machine is upgraded | | `secrets migrate --finalize` | **Optional GC** — drop the old v1 blobs and mark the store pure v2. Never required: upgraded clients dual-write and read-fall-back, so not finalizing never cuts anyone off |
| `secrets recipients list` | List the store's recipient public keys (and names if set) | | `secrets recipients list` | List the store's recipient public keys (and names if set) |
| `secrets recipients add <age1…> [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set | | `secrets recipients add <age1…> [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set |
| `secrets recipients rm <key\|name> [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key | | `secrets recipients rm <key\|name> [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key |
| `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) | | `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) |
| `secrets upgrade` | Self-update the tool: `git pull --ff-only` on the `secrets` checkout, report old → new version, then re-check store version-skew. No auto-update, no background checks |
| `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing |
### Upgrading: do teammates on an older `secrets` get new secrets?
Store-format v2 is **additive** — an upgraded client reads either blob suffix and keeps the old (v1) suffix alive for externals that already existed, so you almost never have to coordinate an upgrade:
| Secret type | Old client gets it? |
|---|---|
| `.env` / `.env.*` / `.dev.vars` | **Yes, always** (blob name is identical across formats) |
| whole-file external (`file`) | **Yes, always** |
| `properties` external that already existed | **Yes** (dual-written so old clients stay fresh) |
| brand-new `properties` external | **No — must upgrade `secrets`** (the gentle forcing function) |
"Upgrade your secrets" = `git pull` the tool clone (binary ≥ 0.6.0.0) and/or `secrets migrate` the store. A read-only teammate only needs the tool `git pull`.
And you'll be told when you're behind: if a store was last written by a newer `secrets` than the one you're running, any command prints a one-line nudge to stderr (non-fatal) — and `secrets which` shows the store's `written-by:` version. Stores written by older builds (no version stamp) stay silent.
### Automatic project detection ### Automatic project detection

View file

@ -1 +1 @@
0.6.2.0 0.7.5.0

View file

@ -0,0 +1,587 @@
# Additive-v2 Dual-Write Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make store-format v2 *additive* — upgraded `secrets` clients read either blob suffix and dual-write existing `properties` externals — so the destructive `migrate --finalize` becomes optional GC and the cross-machine coordination gate disappears.
**Architecture:** Replace the marker-driven single-suffix helper `_external_blob_suffix(type)` with two helpers: a read-resolver that tries `.properties.age` then falls back to `.gradle-properties.age`, and a write-targets helper that writes the v2 suffix always plus the v1 suffix *only when a v1 twin already exists* (dual-write existing externals; brand-new externals are v2-only — the intended forcing function). Reads and writes no longer depend on the `.secrets-format` marker, which keeps its meaning (born-v2 / finalized). Because fresh pushes now write the v2 suffix on any store, the migrate/finalize test fixtures (which relied on push producing a v1 blob) are updated to fabricate an old-client v1 blob.
**Tech Stack:** Single bash 3.2 script (`secrets`); `age`, `git`, `jq`. Tests: `bats-core` (`test/migrate.bats`, `test/manifest.bats`). Every standalone `[[ ]]` test assertion ends with `|| false` (bash 3.2 ERR-trap gotcha).
**Spec:** `docs/superpowers/specs/2026-06-08-additive-v2-dual-write-design.md`. **Ticket:** EGB-712.
---
## Background facts (verified against branch `brian/egb-712-...`, post-EGB-710)
- `_external_blob_suffix()``secrets:548-555`. Returns `properties` for `gradle-properties` on a v2 store (`_store_format == 2`), else the type verbatim; `file` always returns `file`. Blob path = `external/<slug>.<suffix>.age`.
- Callers of `_external_blob_suffix`: push file write `secrets:655`, push properties write `secrets:684`, pull read `secrets:713`, verify read `secrets:2056`. All four are replaced; then the helper is deleted.
- `_secrets_files_slug(path)` (`secrets:512`) derives the machine-independent slug. dotenv and `file` blobs are byte-identical in v1/v2 (only the `properties` suffix diverges).
- `_store_format()` (`secrets:529`) and the marker stay as-is — set only by `init` (born-v2) and `migrate --finalize`. Push must NOT stamp it (see spec §3).
- Existing test that codifies OLD write behavior: `test/migrate.bats:52` "push on a v1 store still writes .gradle-properties.age (back-compat)" — rewritten in Task 2.
- Tests that fabricate-or-rely-on a v1 properties blob from `make_v1_store; push` and break once push writes v2-only (repaired in Task 3): the migrate copy-forward/idempotent tests, the `--dry-run` rename test, the dotenv+file untouched test, all four finalize tests, the EGB-710 manifest-free / undeclared-twin tests, and the two `--status` tests that need an actual v1 blob. (dotenv-only and already-v2 tests are unaffected.)
- Baseline before this plan: `bats test/` = 242 passing.
## File structure
- Modify: `secrets` — delete `_external_blob_suffix` (548-555); add `_resolve_external_blob_read` + `_external_blob_write_targets` in its place; rewire push (651-688), pull (712-713), verify (2052-2068); extend `_migrate_status`; docs in `cmd_help`.
- Modify: `test/migrate.bats` — add `m_fake_v1_blob` helper; add read-fallback + write-rule + status-coverage tests; repair the v1-blob-dependent fixtures.
- Modify: `CLAUDE.md`, `README.md`, `VERSION` (→ `0.7.0.0`), `CHANGELOG.md`.
---
## Task 1: Read-resolver — reads try both suffixes
**Files:** `secrets` (replace `_external_blob_suffix` with the read-resolver; rewire pull + verify reads), `test/migrate.bats`.
- [ ] **Step 1: Write the failing test** — a v2 store whose properties blob exists ONLY in the v1 suffix must still pull.
Add to `test/migrate.bats` (after the format-marker tests, ~line 60):
```bash
@test "pull reads a v1-suffix properties blob on a v2 store (read-fallback)" {
init_with_remote # born-v2 store (marker=2)
m_gradle_src $'beaconClerkPkTest=pk_test_v1\n'
create_project_dir rffallback
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push rffallback >/dev/null 2>&1 # writes .properties.age on a v2 store
# Simulate an external that exists only in the v1 suffix (an old client wrote it):
local v2blob; v2blob=$(ls "$SECRETS_DIR/rffallback/external/"*.properties.age)
mv "$v2blob" "${v2blob%.properties.age}.gradle-properties.age"
rm -f "$HOME/.gradle/gradle.properties"
"$SECRETS_BIN" pull rffallback >/dev/null 2>&1
run grep -q 'beaconClerkPkTest=pk_test_v1' "$HOME/.gradle/gradle.properties"
[ "$status" -eq 0 ]
}
```
- [ ] **Step 2: Run it, confirm it fails**
Run: `bats test/migrate.bats -f "read-fallback"`
Expected: FAIL — old pull (`secrets:713`) uses `_external_blob_suffix gradle-properties` = `properties` on a v2 store, looks only for `.properties.age` (which we renamed away), warns "no encrypted data", restores nothing → the grep fails.
- [ ] **Step 3: Replace `_external_blob_suffix` with the read-resolver.** Replace `secrets:542-555` (the comment block + `_external_blob_suffix()` through its closing `}`) with:
```bash
# Resolve the on-disk path of an external blob for READING. Tries the v2 suffix
# (.properties.age) first, then falls back to the v1 (.gradle-properties.age) for
# `properties` externals, so an upgraded client finds the blob whichever format
# wrote it (additive v2 — EGB-712). `file` externals share one suffix in both
# formats. Echoes the path of the blob that exists; if neither exists, echoes the
# canonical v2 path so the caller's "no blob" message reads sensibly. Read-only.
_resolve_external_blob_read() {
local project="$1" slug="$2" mtype="$3"
local base="$SECRETS_DIR/$project/external/$slug"
case "$mtype" in
file)
echo "$base.file.age" ;;
properties|gradle-properties)
if [ -f "$base.properties.age" ]; then
echo "$base.properties.age"
elif [ -f "$base.gradle-properties.age" ]; then
echo "$base.gradle-properties.age"
else
echo "$base.properties.age"
fi ;;
*)
echo "$base.$mtype.age" ;;
esac
}
```
(The write-targets helper is added in Task 2 — leave a gap; do not reintroduce `_external_blob_suffix`.)
- [ ] **Step 4: Rewire the pull read.** At `secrets:712-713`, replace:
```bash
local slug; slug=$(_secrets_files_slug "$mpath")
local blob="$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age"
```
with:
```bash
local slug; slug=$(_secrets_files_slug "$mpath")
local blob; blob=$(_resolve_external_blob_read "$project" "$slug" "$mtype")
```
- [ ] **Step 5: Rewire the verify read.** At `secrets:2055-2065`, replace:
```bash
slug=$(_secrets_files_slug "$epath")
erel="external/$slug.$(_external_blob_suffix "$etype").age"
# Account for BOTH the v1 and v2 suffix forms in the orphan set. During the
# migration window (after copy-forward, before --finalize) the v2 twin
# coexists with the v1 blob; neither should read as an orphan whichever
# format the store currently reports. (file's two forms are identical.)
expected="${expected}external/$slug.$etype.age"$'\n'
[ "$etype" = "gradle-properties" ] && expected="${expected}external/$slug.properties.age"$'\n'
eblob="$pdir/$erel"
if [ ! -f "$eblob" ]; then
echo "FINDING: external '$epath' ($etype) is declared but has no blob in the store ($project/$erel missing). Run 'secrets push'." >&2
```
with:
```bash
slug=$(_secrets_files_slug "$epath")
# Account for BOTH suffix forms in the orphan set — a dual-written `properties`
# external (additive v2 — EGB-712) legitimately has both blobs on disk; neither
# is an orphan. (file's two forms are identical.)
expected="${expected}external/$slug.$etype.age"$'\n'
[ "$etype" = "gradle-properties" ] && expected="${expected}external/$slug.properties.age"$'\n'
eblob=$(_resolve_external_blob_read "$project" "$slug" "$etype")
erel="${eblob#"$pdir"/}"
if [ ! -f "$eblob" ]; then
echo "FINDING: external '$epath' ($etype) is declared but has no blob in the store ($project/$erel missing). Run 'secrets push'." >&2
```
(Note: `_external_blob_suffix` still has two remaining callers in push — push isn't rewired until Task 2, so the script still parses and runs. Those calls keep working because the function is only deleted in Task 2 Step 6, after push is rewired.)
**IMPORTANT:** do NOT delete `_external_blob_suffix` yet — push (`secrets:655`, `secrets:684`) still calls it until Task 2. Deleting it now breaks push.
- [ ] **Step 6: Run the read-fallback test + full migrate suite**
Run: `bats test/migrate.bats`
Expected: the new "read-fallback" test PASSES; all other migrate tests still PASS (push unchanged; pull/verify now use the resolver, which is equivalent to the old behavior whenever the suffix matches the store format).
- [ ] **Step 7: Commit**
```bash
git add secrets test/migrate.bats
git commit -m "feat: read-resolver tries both external suffixes (additive v2, EGB-712)"
```
---
## Task 2: Write-targets — twin rule (dual-write existing, v2-only for new)
**Files:** `secrets` (add `_external_blob_write_targets`; rewire push file + properties writes; delete `_external_blob_suffix`), `test/migrate.bats`.
- [ ] **Step 1: Write the failing tests.** Add to `test/migrate.bats`:
```bash
@test "push writes the v2 suffix for a fresh external even on a v1 store" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir freshv1
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push freshv1 >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/freshv1/external/*.properties.age"
[ "$status" -eq 0 ] # v2 suffix regardless of marker
run bash -c "ls $SECRETS_DIR/freshv1/external/*.gradle-properties.age 2>/dev/null"
[ "$status" -ne 0 ] # no v1 twin for a brand-new external
}
@test "push dual-writes the v1 twin so old clients stay fresh" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_old\n'
create_project_dir dualwrite
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push dualwrite >/dev/null 2>&1 # v2-only (fresh)
m_fake_v1_twin dualwrite # simulate a pre-existing v1 twin
m_gradle_src $'beaconClerkPkTest=pk_test_new\n'
"$SECRETS_BIN" push dualwrite >/dev/null 2>&1 # twin exists -> dual-write both
# Prove the v1 twin was refreshed: drop the v2 blob, pull, expect the NEW value.
rm -f "$SECRETS_DIR/dualwrite/external/"*.properties.age
rm -f "$HOME/.gradle/gradle.properties"
"$SECRETS_BIN" pull dualwrite >/dev/null 2>&1
run grep -q 'beaconClerkPkTest=pk_test_new' "$HOME/.gradle/gradle.properties"
[ "$status" -eq 0 ]
}
```
- [ ] **Step 2: Add the `m_fake_v1_twin` test helper.** In `test/migrate.bats`, next to `make_v1_store` (~line 12), add:
```bash
# Simulate an old (v1) client's properties blob: copy the pushed v2
# .properties.age to its v1 .gradle-properties.age twin. (Current clients never
# write the v1 suffix for a fresh external, so tests fabricate it.) Use `cp` to
# KEEP the v2 blob (dual present); see m_make_v1_only to leave only the v1 blob.
m_fake_v1_twin() {
local proj="$1" v2
v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age)
cp "$v2" "${v2%.properties.age}.gradle-properties.age"
}
# Like m_fake_v1_twin but renames (leaves ONLY the v1 blob) — for old-client-only
# / copy-forward fixtures.
m_make_v1_only() {
local proj="$1" v2
v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age)
mv "$v2" "${v2%.properties.age}.gradle-properties.age"
}
```
- [ ] **Step 3: Run the new tests, confirm they fail**
Run: `bats test/migrate.bats -f "fresh external even on a v1 store"`
Expected: FAIL — on a v1 store, the old push (`_external_blob_suffix gradle-properties` = `gradle-properties`) writes `.gradle-properties.age`, so the `*.properties.age` assertion fails.
Run: `bats test/migrate.bats -f "dual-writes the v1 twin"`
Expected: FAIL — old push writes a single suffix; the second push won't refresh the v1 twin.
- [ ] **Step 4: Add the write-targets helper.** Immediately after `_resolve_external_blob_read` (added in Task 1), insert:
```bash
# The on-disk path(s) to WRITE for an external blob, one per line. For a
# `properties` external this is the v2 suffix (.properties.age) ALWAYS, plus the
# v1 suffix (.gradle-properties.age) WHEN a v1 twin already exists in the store
# (dual-write keeps old clients fresh; a brand-new external is v2-only — the
# intended forcing function, additive v2 / EGB-712). `file` externals have a
# single suffix in both formats. Independent of the store marker.
_external_blob_write_targets() {
local project="$1" slug="$2" mtype="$3"
local base="$SECRETS_DIR/$project/external/$slug"
case "$mtype" in
file)
echo "$base.file.age" ;;
properties|gradle-properties)
echo "$base.properties.age"
[ -f "$base.gradle-properties.age" ] && echo "$base.gradle-properties.age" ;;
*)
echo "$base.$mtype.age" ;;
esac
}
```
- [ ] **Step 5: Rewire the push writes.** At `secrets:651-658` (the `file` branch), replace:
```bash
if [ "$mtype" = "file" ]; then
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
mkdir -p "$SECRETS_DIR/$project/external"
local fslug; fslug=$(_secrets_files_slug "$mpath")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded"
info "Encrypted file $mpath"
pushed=$((pushed + 1))
continue
fi
```
with:
```bash
if [ "$mtype" = "file" ]; then
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
mkdir -p "$SECRETS_DIR/$project/external"
local fslug; fslug=$(_secrets_files_slug "$mpath")
local wt
while IFS= read -r wt; do
[ -n "$wt" ] || continue
age -r "$pubkey" -o "$wt" "$expanded"
done < <(_external_blob_write_targets "$project" "$fslug" file)
info "Encrypted file $mpath"
pushed=$((pushed + 1))
continue
fi
```
Then at `secrets:682-684` (the properties branch), replace:
```bash
mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp"
```
with:
```bash
mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath")
local wt
while IFS= read -r wt; do
[ -n "$wt" ] || continue
age -r "$pubkey" -o "$wt" "$tmp"
done < <(_external_blob_write_targets "$project" "$slug" "$mtype")
```
- [ ] **Step 6: Delete the now-unused `_external_blob_suffix`.** Confirm zero remaining callers first:
Run: `grep -n "_external_blob_suffix" secrets`
Expected: no matches (all four call sites rewired). If any remain, rewire them before deleting. Then delete the `_resolve_external_blob_read`-replaced... — it's already gone (replaced in Task 1). Verify the function is absent: `grep -c "_external_blob_suffix()" secrets``0`.
- [ ] **Step 7: Run the two new write tests**
Run: `bats test/migrate.bats -f "fresh external even on a v1 store"` then `-f "dual-writes the v1 twin"`
Expected: both PASS.
- [ ] **Step 8: Rewrite the obsolete back-compat test.** Replace the `@test "push on a v1 store still writes .gradle-properties.age (back-compat)"` block (`test/migrate.bats:52-60`) with:
```bash
@test "push on a v1 store writes the v2 suffix for a fresh external (additive v2)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir v1push
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push v1push >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/v1push/external/*.properties.age"
[ "$status" -eq 0 ]
}
```
- [ ] **Step 9: Run the full migrate suite — expect the v1-blob-dependent fixtures to FAIL.** This is expected; Task 3 repairs them.
Run: `bats test/migrate.bats`
Expected: the read-fallback + two write tests + rewritten back-compat test PASS; several copy-forward/finalize/status tests now FAIL (push no longer writes a `.gradle-properties.age` for them to migrate). Note which fail — Task 3 fixes exactly those.
- [ ] **Step 10: Commit** (suite intentionally not yet fully green — Task 3 follows immediately)
```bash
git add secrets test/migrate.bats
git commit -m "feat: twin-rule write targets — dual-write existing, v2-only for new (additive v2, EGB-712)"
```
---
## Task 3: Repair migrate/finalize/status fixtures
**Files:** `test/migrate.bats`. No production code changes — this re-greens the suite by fabricating the old-client v1 blobs that push no longer writes.
The rule for each repair: after the `"$SECRETS_BIN" push <proj>` line, insert a fabrication call:
- Use **`m_make_v1_only <proj>`** (rename → only the v1 blob exists) for tests asserting a `*.gradle-properties.age` blob exists / is copy-forwarded (mirrors the pre-EGB-712 state where push produced a v1 blob).
- Use **`m_fake_v1_twin <proj>`** (keep both) only where a test needs both suffixes present.
- [ ] **Step 1: Repair the copy-forward / dry-run / idempotent tests.** In each of these tests, insert `m_make_v1_only <proj>` immediately after the `push <proj>` line:
- `"migrate --dry-run reports the rename and writes nothing"` (proj `dryproj`)
- `"migrate copy-forward creates the v2 twin and keeps the v1 blob (byte-identical)"` (proj `cfproj`)
- `"migrate copy-forward is idempotent"` (proj `idemproj`)
- `"migrate leaves dotenv and file blobs untouched"` (proj `mixproj`) — note this one ALSO pushes a `file` external; `m_make_v1_only` only touches `*.properties.age`, leaving the `.file.age` blob alone (correct).
Worked example — the copy-forward test becomes:
```bash
@test "migrate copy-forward creates the v2 twin and keeps the v1 blob (byte-identical)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir cfproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push cfproj >/dev/null 2>&1
m_make_v1_only cfproj
local old; old=$(ls "$SECRETS_DIR/cfproj/external/"*.gradle-properties.age)
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
local new; new=$(ls "$SECRETS_DIR/cfproj/external/"*.properties.age)
[ -f "$old" ]
[ -f "$new" ]
cmp -s "$old" "$new"
}
```
- [ ] **Step 2: Repair the finalize tests.** Insert `m_make_v1_only <proj>` after the `push <proj>` line in:
- `"finalize refuses when verify --all is not green"` (proj `failverify`) — then the existing `migrate` step creates the twin; corrupting `*.properties.age` still trips verify.
- `"finalize refuses an un-twinned v1 blob (project not migrated)"` (proj `untwinned`) — leaves a lone v1 blob, no twin: exactly the un-twinned state the test wants.
- `"finalize green path drops v1, keeps v2, stamps the marker"` (proj `finproj`).
- `"finalize cuts a recovery tag before deleting v1 blobs"` (proj — read it from the test).
- [ ] **Step 3: Repair the EGB-710 manifest-free / undeclared-twin tests.** Insert `m_make_v1_only <proj>` after the `push <proj>` line in:
- `"migrate copy-forwards a v1 properties blob with no .secrets.json (manifest-free)"` (proj `nomanifestblob`) — insert BEFORE the `rm -f .secrets.json` line.
- `"migrate twins a store blob even when the manifest no longer declares it"` (proj `staleblob`) — insert before the `.secrets.json` rewrite.
- [ ] **Step 4: Repair the `--status` tests that need a real v1 blob.**
- `"migrate --status flags a project that needs migrating"` (proj `needsmig`) — insert `m_make_v1_only needsmig` after push, so a lone un-twinned v1 blob exists → NEEDS MIGRATE.
- `"migrate --status reports finalize-ready once every blob is twinned"` (proj `readymig`) — insert `m_make_v1_only readymig` after push and BEFORE the `migrate` step (migrate then creates the twin → finalize-ready).
- [ ] **Step 5: Run the full migrate suite**
Run: `bats test/migrate.bats`
Expected: ALL pass. If any copy-forward test still reports "nothing to migrate", its `m_make_v1_only` call is missing or misplaced (must come after push, before migrate).
- [ ] **Step 6: Run the WHOLE suite** (manifest.bats exercises externals end-to-end and must still be green)
Run: `bats test/`
Expected: all pass. If a `manifest.bats` external test fails, check it isn't asserting a specific suffix that additive-v2 changed (a fresh push now writes `.properties.age`); update such an assertion the same way (assert `.properties.age`, or use the resolver-agnostic round-trip via pull).
- [ ] **Step 7: Commit**
```bash
git add test/migrate.bats
git commit -m "test: fabricate old-client v1 blobs in migrate/finalize/status fixtures (additive v2, EGB-712)"
```
---
## Task 4: `migrate --status` — dual-write coverage line
**Files:** `secrets` (`_migrate_status`, `secrets:2190-2227`), `test/migrate.bats`.
Adds visibility into which `properties` externals are v2-only (old clients can't read them — the forcing function) vs dual-written (old clients still served).
- [ ] **Step 1: Write the failing test.** Add to `test/migrate.bats`:
```bash
@test "migrate --status counts v2-only externals (old clients not served)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir v2onlyext
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push v2onlyext >/dev/null 2>&1 # v2-only (fresh, no v1 twin)
run "$SECRETS_BIN" migrate --status
[ "$status" -eq 0 ] # no v1 blobs -> finalize-ready
[[ "$output" == *"v2-only"* ]] || false # surfaced as v2-only coverage
}
```
- [ ] **Step 2: Run it, confirm it fails**
Run: `bats test/migrate.bats -f "counts v2-only externals"`
Expected: FAIL — `_migrate_status` currently only counts `*.gradle-properties.age`; it never mentions `v2-only`.
- [ ] **Step 3: Extend `_migrate_status`.** In `_migrate_status` (`secrets:2190`), inside the `for dir` loop, after the existing `while ... done < <(find "$dir" -type f -name '*.gradle-properties.age' ...)` block and before the per-project classification, add a v2-only count, then surface it in the per-project line. Concretely, replace the classification block:
```bash
if [ "$v1" -eq 0 ]; then
echo " $project: v2-ready (no v1 properties blobs)"
elif [ "$untwinned" -eq 0 ]; then
echo " $project: migrated ($v1 v1 blob(s), all twinned)"
else
echo " $project: NEEDS MIGRATE ($untwinned of $v1 v1 blob(s) un-twinned) — cd into the project and run 'secrets migrate'"
any_untwinned=1
fi
```
with:
```bash
# v2-only externals: a .properties.age with no .gradle-properties.age twin —
# old (v1) clients cannot read these (the additive-v2 forcing function).
local v2only=0 pf
while IFS= read -r pf; do
[ -f "$pf" ] || continue
[ -f "${pf%.properties.age}.gradle-properties.age" ] || v2only=$((v2only + 1))
done < <(find "$dir" -type f -name '*.properties.age' 2>/dev/null)
local v2note=""
[ "$v2only" -gt 0 ] && v2note=" [$v2only v2-only — old clients not served]"
if [ "$v1" -eq 0 ]; then
echo " $project: v2-ready (no v1 properties blobs)$v2note"
elif [ "$untwinned" -eq 0 ]; then
echo " $project: migrated ($v1 v1 blob(s), all twinned)$v2note"
else
echo " $project: NEEDS MIGRATE ($untwinned of $v1 v1 blob(s) un-twinned) — cd into the project and run 'secrets migrate'$v2note"
any_untwinned=1
fi
```
(`local` inside the loop is bash-3.2-fine — it re-declares per iteration.)
- [ ] **Step 4: Run the new test + status suite**
Run: `bats test/migrate.bats -f "status"`
Expected: all status tests PASS, including the new v2-only one.
- [ ] **Step 5: Run the full suite**
Run: `bats test/`
Expected: all pass.
- [ ] **Step 6: Commit**
```bash
git add secrets test/migrate.bats
git commit -m "feat: migrate --status surfaces v2-only externals (coverage, EGB-712)"
```
---
## Task 5: Docs, help, version, changelog
**Files:** `secrets` (`cmd_help`), `CLAUDE.md`, `README.md`, `VERSION`, `CHANGELOG.md`.
- [ ] **Step 1: `cmd_help` — reframe finalize as optional.** In `cmd_help()`, replace the finalize line:
```
secrets migrate --finalize Drop v1 blobs and mark the store v2 (after verify)
```
with:
```
secrets migrate --finalize Optional GC: drop v1 blobs and mark the store pure v2
```
- [ ] **Step 2: `CLAUDE.md` — additive-v2 paragraph.** In the "Store format" bullet (line ~65), after the migration-chain sentence, add (new sentence, same bullet):
```
Additive v2 (EGB-712): upgraded clients read either blob suffix (`_resolve_external_blob_read` tries `.properties.age` then `.gradle-properties.age`) and dual-write a `properties` external only when a v1 twin already exists (`_external_blob_write_targets`) — so existing externals keep old clients fresh, brand-new externals are v2-only (a gentle forcing function), and `migrate --finalize` is now OPTIONAL GC rather than a required, coordination-gated flag-day. dotenv and `file` blobs are identical across formats, so they always propagate to old clients.
```
- [ ] **Step 3: `README.md` — propagation table.** Add, near the migrate rows in the command table or in a short "Upgrading / store format" subsection, the propagation-by-secret-type summary (verbatim from spec §6):
```markdown
**Do teammates on an older `secrets` get new secrets?**
| Secret type | Old client gets it? |
|---|---|
| `.env` / `.env.*` / `.dev.vars` | **Yes, always** (blob name identical across formats) |
| whole-file external | **Yes, always** |
| `properties` external that already existed | **Yes** (dual-written) |
| brand-new `properties` external | **No — must upgrade `secrets`** (the forcing function) |
"Upgrade your secrets" = `git pull` the tool clone (binary ≥ 0.6.0.0) and/or `secrets migrate` the store. A read-only teammate only needs the tool `git pull`.
```
- [ ] **Step 4: `VERSION`** — set to `0.7.0.0`.
- [ ] **Step 5: `CHANGELOG.md`** — insert above the top entry:
```markdown
## [0.7.0.0] - 2026-06-08
### Changed
- **Additive store-format v2 (EGB-712)** — upgraded `secrets` clients now read
either external blob suffix (`.properties.age` or the legacy
`.gradle-properties.age`) and **dual-write** a `properties` external whenever a
v1 twin already exists in the store. Existing externals keep working for
teammates on an older `secrets`; only a brand-new `properties` external is
written v2-only (a gentle "upgrade to see it" forcing function). dotenv and
whole-`file` externals are unchanged across formats and always propagate.
- **`secrets migrate --finalize` is now optional GC**, not a required milestone.
Because clients dual-write and read-fall-back, no teammate is ever cut off by
*not* finalizing; finalize only reclaims the duplicate v1 blobs, and stays
deferrable indefinitely. Its safety gates are unchanged.
### Added
- **`secrets migrate --status`** now reports `v2-only` externals per project
(the ones an un-upgraded client cannot read), so you can see the forcing
function's footprint at a glance.
```
- [ ] **Step 6: Run the full suite**
Run: `bats test/`
Expected: all pass (docs don't affect tests). Confirm the count is baseline 242 + net new tests from Tasks 1/2/4 (read-fallback, two write tests, v2-only status) minus the rewritten back-compat test (replaced, not added) = **246**.
- [ ] **Step 7: Sanity — help renders**
Run: `./secrets help 2>&1 | grep -- "--finalize"`
Expected: shows the reframed "Optional GC" line. (Help only prints; touches no store.)
- [ ] **Step 8: Commit**
```bash
git add secrets CLAUDE.md README.md VERSION CHANGELOG.md
git commit -m "docs: additive-v2 propagation + optional-GC finalize; bump 0.7.0.0 (EGB-712)"
```
---
## Self-review against the spec
- **§1 read resolution** → Task 1 (`_resolve_external_blob_read`, wired into pull + verify). Test: read-fallback.
- **§2 write rule / twin rule** → Task 2 (`_external_blob_write_targets`, push wiring). Tests: fresh-→v2-only, existing-twin-→dual-write.
- **§3 marker NOT stamped** → no production change (push never touches the marker); guarded implicitly by Task 3 keeping the `make_v1_store; push; migrate` flow working (store stays markerless after push). The plan deliberately does not add marker-stamping.
- **§4 finalize = optional GC** → unchanged logic; reframed in Task 5 docs/help. Existing finalize tests stay green (Task 3 keeps them green).
- **§5 migrate / --status coverage** → Task 4 (v2-only line); `migrate` copy-forward unchanged (EGB-710).
- **§6 propagation table** → Task 5 README/CLAUDE.md.
- **§7 caveat** → documented in CHANGELOG/CLAUDE.md framing; the read-fallback test exercises the v1-only read path.
- **§8 back-compat matrix** → covered across Task 1 (reads), Task 2 (writes), Task 3 (old-client v1 blobs simulated).
- **Safety invariants** → no path-rail changes; bash 3.2 (no associative arrays; `while read` + `find`); recursive walks unchanged; finalize gating untouched.
**Type/name consistency:** `_resolve_external_blob_read(project, slug, mtype)` and `_external_blob_write_targets(project, slug, mtype)` use the same arg order everywhere; test helpers `m_fake_v1_twin` (keep both) and `m_make_v1_only` (rename to v1-only) are used consistently per their documented semantics.
**Placeholder scan:** none — every step carries verbatim code or an exact enumerated edit with the precise insertion point.
## Operator-local follow-up (not part of this plan)
Per `.ship-policy.json`, before any PR ask the operator to run `./test/run-security.sh` and complete the SIGNOFF. EGB-712 also needs its one stale AC bullet ("Marker auto-stamps…") corrected to match the §3 decision (no auto-stamp) — a one-line Linear edit.

View file

@ -0,0 +1,300 @@
# EGB-713: Version-skew nudge Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / subagent-driven-development. Steps use `- [ ]`.
**Goal:** Warn (non-fatally) when the active store was last written by a newer `secrets` version than the running client, so a behind user is told to update — the loud counterpart to EGB-712's quiet forcing function.
**Architecture:** Stamp the store with the highest writer `VERSION` seen (`$SECRETS_DIR/.secrets-writer-version`, committed, monotonic) on every store-committing write. On any store command, compare that stamp to the client's own `VERSION` (read from `$SCRIPT_DIR/VERSION`); if the stamp is newer, print a one-time stderr nudge. Legacy stores with no stamp are silent.
**Tech Stack:** bash 3.2 (`secrets`); bats. Spec/idea: ticket **EGB-713**.
## Background (verified against `main`, post-EGB-712)
- `SCRIPT_DIR` already defined at `secrets:21` (`$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)`). The repo-root `VERSION` file lives next to the script.
- Store-committing write sites (each does `git -C "$SECRETS_DIR" add -A`): `commit_and_push_secrets()` `secrets:1265` (push), rekey `secrets:1822`, migrate copy-forward `secrets:2215`, finalize no-v1 `secrets:2305`, finalize drop `secrets:2340`. Plus `cmd_init` (born store) and `cmd_rm`.
- `ensure_store_protections()` (`secrets:1144`) is shared with pull (read) — do NOT stamp there.
- `check_initialized()` (`secrets:54`) early-returns when `$SECRETS_DIR/.git` exists and is called by push/pull/list/rm/rekey/verify/migrate — the natural warning hook.
- `cmd_which()` (`secrets:1945`) prints `format: v$(_store_format)` — add the writer-version line here.
- `.gitignore` only ignores `key.txt`, so `.secrets-writer-version` commits normally.
- VERSION currently `0.7.0.0` → bump to `0.7.1.0`.
- Baseline: `bats test/` = 246 passing. New tests land in a new file `test/version.bats`.
---
## Task 1: Version helpers + comparator (TDD)
**Files:** `secrets`, `test/version.bats` (new).
- [ ] Step 1: Create `test/version.bats` testing the comparator via a tiny harness that sources the script's functions is awkward (the script runs main). Instead test through observable behavior in later tasks; for the comparator, add a hidden debug subcommand is overkill. Use this approach: test `_version_gt` indirectly by exporting it is not possible. So test the comparator by adding the helpers and a **`secrets __vercmp <a> <b>`** internal is overkill too. Decision: test the comparator's *effect* in Task 3 (warning) and Task 2 (stamp monotonicity), which exercise it end-to-end. For Task 1, write the helpers and verify with a one-off `bash -c` sourcing guard.
Add to `test/version.bats`:
```bash
load test_helper
# Exercises the comparator through a bash subshell that defines the same logic
# the script uses, guarding the numeric (not lexical) ordering contract.
@test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically" {
run bash -c '
_version_gt() {
local a="$1" b="$2" i ai bi; local -a af bf
IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b"
for i in 0 1 2 3; do
ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0
bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0
if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi
if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi
done; return 1
}
_version_gt 0.10.0.0 0.7.0.0 && echo "10gt7"
_version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10"
_version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt"
'
[ "$status" -eq 0 ]
[[ "$output" == *"10gt7"* ]] || false
[[ "$output" == *"7not_gt_10"* ]] || false
[[ "$output" == *"equal_not_gt"* ]] || false
}
```
- [ ] Step 2: Run `bats test/version.bats` → PASS (pins the contract the script must match).
- [ ] Step 3: Add the helpers to `secrets` (near `_store_format`, after `SCRIPT_DIR`/version constants — place after the `MANIFEST_VERSION=2` area or near `_store_format`):
```bash
# The running client's own version, read from the VERSION file shipped beside
# the script. Empty/"0.0.0.0" if absent (e.g. an odd install) — treated as
# "unknown/oldest" so a missing VERSION never triggers a spurious nudge.
_client_version() {
local v=""
[ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
printf '%s' "${v:-0.0.0.0}"
}
# Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2.
# Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0.
_version_gt() {
local a="$1" b="$2" i ai bi; local -a af bf
IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b"
for i in 0 1 2 3; do
ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0
bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0
if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi
if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi
done
return 1
}
WRITER_VERSION_FILE_NAME=".secrets-writer-version"
# Highest client version recorded as having written to the store (empty if the
# store predates this feature — "minus the initial builds", silent by design).
_store_writer_version() {
local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME"
[ -f "$f" ] && head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]'
}
```
- [ ] Step 4: `bash -n secrets` parses; `bats test/` still 246 + 1 (the comparator test) = 247.
- [ ] Step 5: Commit: `git add secrets test/version.bats && git commit -m "feat: version helpers + numeric comparator (EGB-713)"`
---
## Task 2: Stamp the writer-version on write (TDD)
**Files:** `secrets`, `test/version.bats`.
- [ ] Step 1: Add tests:
```bash
@test "push stamps the store writer-version with the client version" {
init_with_remote
create_project_dir wvstamp
"$SECRETS_BIN" push wvstamp >/dev/null 2>&1
[ -f "$SECRETS_DIR/.secrets-writer-version" ]
run cat "$SECRETS_DIR/.secrets-writer-version"
[ "$output" = "$(cat "$(dirname "$SECRETS_BIN")/VERSION")" ]
}
@test "writer-version stamp is monotonic (a push never lowers a higher stamp)" {
init_with_remote
create_project_dir wvmono
printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version"
"$SECRETS_BIN" push wvmono >/dev/null 2>&1
run cat "$SECRETS_DIR/.secrets-writer-version"
[ "$output" = "9.9.9.9" ] # not lowered to the client's version
}
@test "writer-version stamp is committed, not gitignored" {
init_with_remote
create_project_dir wvcommit
"$SECRETS_BIN" push wvcommit >/dev/null 2>&1
run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version"
[ "$status" -eq 0 ]
}
```
- [ ] Step 2: Run `bats test/version.bats -f "stamp"` → FAIL (no stamping yet).
- [ ] Step 3: Add the stamp helper (after `_store_writer_version`):
```bash
# Raise the store's recorded writer-version to the client's version (monotonic;
# never lowers it). Called right before each store-committing `git add -A` so
# the stamp rides the same commit. Read paths (pull) never call this.
_stamp_writer_version() {
local cur cli
cur=$(_store_writer_version)
cli=$(_client_version)
if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then
printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME"
fi
}
```
- [ ] Step 4: Call `_stamp_writer_version` immediately before each store-committing `git -C "$SECRETS_DIR" add -A`:
- `secrets:1265` (in `commit_and_push_secrets`, before `git add -A`)
- `secrets:1822` (rekey)
- `secrets:2215` (migrate copy-forward)
- `secrets:2305` (finalize, no-v1 path)
- `secrets:2340` (finalize, drop path)
Also in `cmd_init`, after the store repo is created and before its first commit (so a born store records its version), and in `cmd_rm` before its commit.
Each insertion is the single line ` _stamp_writer_version` at the matching indentation directly above the `git ... add -A` (or before the `git ... commit` where there's no add -A, e.g. rm/init — there, stamp then ensure it's staged via the existing add/commit).
- [ ] Step 5: `bats test/version.bats` → all pass. `bats test/` → 250 (247 + 3).
- [ ] Step 6: Commit: `git add secrets test/version.bats && git commit -m "feat: stamp store writer-version on write, monotonic (EGB-713)"`
---
## Task 3: Skew warning on command (TDD)
**Files:** `secrets`, `test/version.bats`.
- [ ] Step 1: Add tests:
```bash
@test "a store written by a newer version warns on a command (non-fatal)" {
init_with_remote
create_project_dir skewwarn
"$SECRETS_BIN" push skewwarn >/dev/null 2>&1
printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" list
[ "$status" -eq 0 ] # non-fatal
[[ "$output" == *"newer"* || "$output" == *"update"* ]] || false
}
@test "a store at the same/older version is silent" {
init_with_remote
create_project_dir noskew
"$SECRETS_BIN" push noskew >/dev/null 2>&1 # stamp == client version
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" != *"update your secrets"* ]] || false
}
@test "a store with no writer-version marker is silent (legacy store)" {
init_with_remote
create_project_dir legacynostamp
"$SECRETS_BIN" push legacynostamp >/dev/null 2>&1
rm -f "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" != *"update your secrets"* ]] || false
}
```
- [ ] Step 2: Run `bats test/version.bats -f "skew\|silent\|legacy"` → the "newer" test FAILS (no warning yet).
- [ ] Step 3: Add the skew check (after `_stamp_writer_version`):
```bash
# Warn ONCE per invocation if the store was last written by a newer client than
# us. Non-fatal (read/write paths keep their exit codes). Silent when the store
# carries no writer-version (legacy) or is same/older than us.
_VERSION_SKEW_WARNED=0
_check_store_version_skew() {
[ "$_VERSION_SKEW_WARNED" = 1 ] && return 0
local sv cv
sv=$(_store_writer_version)
[ -n "$sv" ] || return 0
cv=$(_client_version)
if _version_gt "$sv" "$cv"; then
_VERSION_SKEW_WARNED=1
echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2
echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2
fi
return 0
}
```
- [ ] Step 4: Hook it into `check_initialized` — change `secrets:55-57`:
```bash
if [ -d "$SECRETS_DIR/.git" ]; then
return
fi
```
to:
```bash
if [ -d "$SECRETS_DIR/.git" ]; then
_check_store_version_skew
return
fi
```
- [ ] Step 5: `bats test/version.bats` → all pass. `bats test/` → 253.
- [ ] Step 6: Commit: `git add secrets test/version.bats && git commit -m "feat: warn on store version skew (once per invocation, EGB-713)"`
---
## Task 4: `secrets which` surfaces the writer-version (TDD)
**Files:** `secrets`, `test/version.bats`.
- [ ] Step 1: Add test:
```bash
@test "which prints the store writer-version and a behind note" {
init_with_remote
create_project_dir whichwv
"$SECRETS_BIN" push whichwv >/dev/null 2>&1
printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"written-by: v99.0.0.0"* ]] || false
[[ "$output" == *"behind"* || "$output" == *"update"* ]] || false
}
```
- [ ] Step 2: Run → FAIL (which doesn't print written-by).
- [ ] Step 3: In `cmd_which`, after the `echo "format: v$(_store_format)"` line (`secrets:1951`), add:
```bash
local _wv; _wv=$(_store_writer_version)
if [ -n "$_wv" ]; then
local _cv; _cv=$(_client_version)
if _version_gt "$_wv" "$_cv"; then
echo "written-by: v$_wv (you're on v$_cv — behind; run: git -C \"$SCRIPT_DIR\" pull)"
else
echo "written-by: v$_wv"
fi
fi
```
Note: `cmd_which` calls `resolve_store` but may not call `check_initialized`, so this also avoids double-printing the skew NOTE; the `which` line is the dedicated surface.
- [ ] Step 4: `bats test/version.bats` → pass. `bats test/` → 254.
- [ ] Step 5: Commit: `git add secrets test/version.bats && git commit -m "feat: secrets which shows store writer-version + behind note (EGB-713)"`
---
## Task 5: Docs + version bump
**Files:** `secrets` (cmd_help unchanged unless adding a note), `CLAUDE.md`, `README.md`, `VERSION`, `CHANGELOG.md`.
- [ ] Step 1: `CLAUDE.md` — add to the "Store format" bullet a sentence on the writer-version: a committed `.secrets-writer-version` records the highest client `VERSION` that has written (monotonic, stamped on store-committing writes); commands warn once (stderr, non-fatal) when the store's stamp exceeds the running client, and `secrets which` shows `written-by: vN`. Legacy stores (no marker) are silent. (EGB-713.)
- [ ] Step 2: `README.md` — under the upgrading section, note that an out-of-date `secrets` prints a one-line "update" nudge when it touches a store newer than itself.
- [ ] Step 3: `VERSION``0.7.1.0`.
- [ ] Step 4: `CHANGELOG.md` — new `## [0.7.1.0] - 2026-06-08` with an Added entry for the version-skew nudge + `secrets which` writer-version line.
- [ ] Step 5: `bats test/` → all green (254). `./secrets which` against a scratch store renders (covered by tests).
- [ ] Step 6: Commit: `git add secrets CLAUDE.md README.md VERSION CHANGELOG.md && git commit -m "docs: version-skew nudge + writer-version; bump 0.7.1.0 (EGB-713)"`
---
## Self-review vs ticket AC
- "Newer stamp → nudge; same/older → silent" → Task 3.
- "No marker → silent (legacy)" → Task 3 + `_store_writer_version` empty.
- "Monotonic, committed" → Task 2.
- "Numeric comparator (0.7.0.0 < 0.10.0.0)" Task 1.
- "Non-fatal, never changes read/pull exit codes" → Task 3 (`_check_store_version_skew` always `return 0`).
- "`which` surfaces it" → Task 4. "Warn once per invocation" → `_VERSION_SKEW_WARNED` guard.
- bash 3.2: `read -a`, `local -a`, `10#`, parameter strips — all 3.2-safe.

View file

@ -0,0 +1,216 @@
# Additive v2 — automatic dual-write (no flag-day finalize)
**Status:** Design approved (2026-06-08), pending implementation plan.
**Supersedes the framing of:** EGB-703 ("finalize is the destructive milestone you must reach") and EGB-709 ("delete v1 paths once every client is v2").
**Relates to:** EGB-677 (storage-model unification), EGB-710 (manifest-free migrate).
## Problem
Store-format v2 (EGB-703) renamed the `properties` external blob suffix
(`.gradle-properties.age``.properties.age`) and made the transition a
three-step migration ending in a **destructive** `secrets migrate --finalize`
that drops the v1 blobs. Finalize is gated on "every machine and store in use is
v2" — a coordination requirement that's trivial for a solo dev but **uncertain
for a distributed team of 23+**: there is no reliable "all-clear" signal, and
finalizing early silently cuts an un-upgraded client off from `properties`
externals (it looks for `.gradle-properties.age`, which is gone).
The hard gate exists **only** because finalize permanently drops the v1 blobs.
Remove the obligation to drop, and there is nothing to coordinate.
## Decision
Adopt **Strategy B — Additive v2**: "becoming v2" stops being a destructive
milestone and becomes a property an upgraded client maintains **automatically**.
Reads try both suffixes; writes keep alive whatever old clients already knew.
There is no required `migrate` ceremony and no required `finalize` — **upgrading
the `secrets` tool *is* the migration.** `finalize` survives only as optional,
indefinitely-deferrable garbage collection.
Direction chosen over the alternatives: "park it / never finalize" (leaves the
two-format wart in place, resolves nothing) and "abandon the suffix rename"
(reverts shipped EGB-703 behavior, keeps the ugly `gradle-properties` suffix
forever). Additive v2 is the only option that reaches a clean v2 end-state
*without* a coordinated flag-day.
## Design
### 1. Read resolution — suffix-agnostic
Any upgraded client resolving a `properties` external blob looks for the v2
suffix `.properties.age` first, then falls back to the v1 `.gradle-properties.age`.
An upgraded client therefore **never fails to find a blob** regardless of which
suffix is on disk. Old (pre-0.6.0.0) clients still read v1-only — shipped code
cannot be changed.
Code: today's `_external_blob_suffix(type)` (a single suffix string) is replaced
by a resolver that returns the **path of the blob that exists** for a given
`(project, slug, type)`, trying v2 then v1. `file` externals are unchanged in
both formats, so the resolver is a no-op identity for them (single suffix
`.file.age`).
### 2. Write rule — the self-managing twin rule
On push of a `properties` external, the client checks the store for an existing
v1 twin (`<slug>.gradle-properties.age`):
- **v1 twin exists** (the external was first pushed by an old client) →
**dual-write both suffixes.** Old clients stay fresh forever; nobody is cut off.
- **No v1 twin exists** (brand-new external, first pushed by an upgraded client) →
**write v2-only (`.properties.age`).** Old clients cannot see it → the gentle,
*intended* forcing function. It only ever bites on genuinely new externals,
never on anything that previously worked.
No stored state is required: the presence/absence of the v1 twin **is** the
signal. Mental model: *"keep alive what old clients already knew; new things are
v2-only."*
### 3. The marker — unchanged meaning, NOT auto-stamped
Additive-v2 read/write behavior does **not** depend on the `.secrets-format`
marker at all (reads try both suffixes; writes follow the twin rule). So the
marker is left exactly as it is today: set only by `init` (born-v2, a fresh pure-v2
store) or `migrate --finalize` (a store GC'd to pure v2). A transitional store
that upgraded clients are dual-writing stays **markerless (v1)** — which is
*accurate*: it has not been finalized to pure v2, and v1 twins still exist.
Push deliberately does **not** auto-stamp the marker. (An earlier draft proposed
auto-stamping on first push; that was dropped during planning because it would
make a v1 store read as v2 the moment anyone pushed — turning every `migrate`
into a no-op and contradicting the "v1 until finalized" model the whole
migration relies on. The marker's only purposes — `secrets which` display and
gating `migrate`/`finalize` — are better served by it continuing to mean
"finalized/pure v2.")
`secrets which` continues to report `format: vN` from the marker.
### 4. `finalize` → optional GC, never required
`secrets migrate --finalize` remains the *only* operation that stops dual-writing
and drops the v1 twins. It is pure space reclamation (kilobytes), still
coordination-gated **if** you choose to run it, but never obligatory and
deferrable forever. Its existing safety posture is unchanged (recovery tag,
`verify --all` green gate, twin-before-drop, `--yes`/operator confirmation). This
is what defuses the gate: the destructive step still exists but is now optional
housekeeping, not a release blocker.
### 5. `migrate` / `migrate --status` → normalize + inspect
- `secrets migrate` (the EGB-710 manifest-free copy-forward) stays as an optional
"backfill v2 twins for existing v1-only externals" command — useful right
before a `finalize`. With read-fallback (§1) it is no longer required for
correctness, only for tidiness.
- `secrets migrate --status` becomes the **coverage survey**: per external, which
suffixes exist, and whether old clients are still being served (i.e. whether a
v1 twin is still present and being dual-written). This is the operator's
dashboard for "is anyone still relying on v1?" before an optional `finalize`.
### 6. Propagation semantics (→ user docs verbatim)
"Has not migrated" means **old client** (`secrets` < 0.6.0.0), not "hasn't run
`migrate`". `migrate` is a per-store op done once by anyone; what protects a
teammate is their **client version**. A read-only teammate needs only the tool
`git pull` (binary ≥ 0.6.0.0), not to run `migrate` themselves. "Upgrade your
secrets" (git pull the tool and/or migrate the store) is the correct umbrella —
and for a solo dev the two are one motion.
Does a teammate on an old client get a secret User 1 just added?
| Secret type | Blob name v1 vs v2 | Old client gets the new secret? |
|---|---|---|
| **dotenv** (`.env`, `.env.*`, `.dev.vars`) | identical (`.env.age`) | **Yes, always.** No forcing function possible — the blob name never changed. |
| **`file` external** (keystore, etc.) | identical (`.file.age`) | **Yes, always.** |
| **`properties` external** — existing (has a v1 twin) | dual-written | **Yes.** Old client reads the maintained `.gradle-properties.age`. |
| **`properties` external** — brand-new (no v1 twin) | v2-only | **No → must upgrade.** The forcing function; rare, and never breaks anything that previously worked. |
Net: a teammate on an old client keeps getting **all** everyday `.env` updates
indefinitely (a good safety property — no one silently misses everyday secrets),
and only hits a wall on a genuinely new `properties`-style external.
### 7. Known caveat (documented, not engineered around)
Dual-write keeps v1 *readers* fresh, but a v1 *writer* writes only
`.gradle-properties.age`. A v2 reader (reading `.properties.age` first) could
therefore read stale data until that external is re-pushed by an upgraded client.
For the normal shape — one writer per external, who upgrades first — it never
bites. This matches today's reality and is documented rather than solved
(solving it would require timestamp/newest-wins arbitration across two encrypted
blobs — YAGNI for v1).
### 8. Backward-compatibility matrix
| Actor | dotenv / `file` | `properties` (existing twin) | `properties` (new, v2-only) |
|---|---|---|---|
| Upgraded client reads | ✓ | ✓ (resolver finds either) | ✓ |
| Upgraded client writes | unchanged | dual-writes both | writes v2-only |
| Old client reads | ✓ | ✓ (reads maintained v1 twin) | ✗ (forcing function) |
| Old client writes | unchanged | writes v1 twin only (see §7) | n/a (can't create v2) |
## Code-level surface (for the implementation plan)
- **`_external_blob_suffix(type)`** → split into:
- `_resolve_external_blob_read(project, slug, type)` — returns the path of the
blob that exists, trying `.properties.age` then `.gradle-properties.age` for
`properties`; identity for `file`. Used by `pull_external_files`, `verify`,
and any read path.
- `_external_blob_write_targets(project, slug, type)` — returns the suffix
path(s) to write: for `properties`, both suffixes when a v1 twin already
exists, else v2-only; single path for `file`.
- **`push_external_files`** — write to every path from
`_external_blob_write_targets` (was a single `age -o`). Push does **not** stamp
the marker (see §3).
- **`pull_external_files` / `cmd_verify`** — resolve blobs via
`_resolve_external_blob_read` (was the single-suffix lookup).
- **`_migrate_finalize`** — unchanged logic; doc/help reframed as optional GC.
- **`_migrate_project` / `_migrate_status`** — retained (EGB-710); `--status`
extended to report dual-write coverage per external.
- **Docs:** CLAUDE.md "Store format" bullet, README, `cmd_help`, CHANGELOG,
VERSION bump (minor — new write semantics).
## Safety / constraints (unchanged invariants)
- bash 3.2 portable; every store walk stays recursive (`find -type f`).
- Path-validation rails (`_validate_external_target_path`, slug derivation,
symlink/`..` refusal) untouched.
- `finalize`'s destructive gating (recovery tag, verify-green, twin-before-drop,
`--yes`) untouched.
- The store still carries no manifest; `.secrets.json` remains per-project.
## Test plan (bats, outline)
1. Read-fallback: a v2 client resolves a `properties` external that exists only
as `.gradle-properties.age` (no twin) — pull succeeds.
2. Twin rule — existing twin → dual-write: push an external that has a v1 twin;
assert **both** suffixes are written and an old-client read path (v1 suffix)
sees the fresh value.
3. Twin rule — new external → v2-only: push a brand-new `properties` external;
assert **only** `.properties.age` is written (no v1 twin created) — the
forcing function.
4. Marker NOT stamped on push: a `make_v1_store` + push leaves `.secrets-format`
absent (store stays v1/transitional); the existing `make_v1_store; push;
migrate` flow is unaffected.
5. dotenv/`file` unaffected: a dotenv and a `file` external round-trip identically
regardless of store marker.
6. `migrate --status` coverage: reports which externals are dual-written vs
v2-only.
7. `finalize` still green: existing finalize gates and drop behavior unchanged.
8. Caveat is observable (optional): a v1-suffix-only update is read by the v2
client via fallback (documents the one-writer assumption).
## Ripple to the roadmap
- **EGB-703**: "finalize is the destructive milestone you must reach" → "finalize
is optional GC." Update the ticket/notes.
- **EGB-709** (collapse v1 paths): no longer gates on "all clients v2." Becomes
"delete the dual-write/transitional code **if/when** every store is GC'd to
pure v2" — much later, low stakes. Add a note to EGB-709.
- A new ticket should track this work (additive-v2 dual-write).
## Decided knobs (no longer open)
- Migration ceremony: **automatic** (upgraded client dual-writes on push; no
required `migrate`). `migrate`/`--status` remain manual/inspection tools.
- Forcing function: **kept**, scoped to brand-new `properties` externals via the
twin rule (existing twins always dual-written).
- `finalize`: **kept** as optional GC (not removed), so a fully-upgraded store
can still be reclaimed to pure v2.

113
install.sh Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# secrets — thin onboarding bootstrap (EGB-671).
#
# This script ships INSIDE the repo: you already cloned the repo to get it, so
# its only jobs are (1) verify the dependencies the tool needs and (2) print the
# exact commands to finish setup. It deliberately does NOT:
# - edit your shell rc files (it prints the PATH line for you to paste)
# - invoke sudo or install packages behind your back (it prints the command)
# - re-implement any of the tool's security logic
#
# This is a security tool whose whole pitch is "verify, don't trust" — so the
# installer holds itself to a higher bar than convenience, not a lower one.
#
# Usage:
# ./install.sh # check deps, print setup + next steps
# ./install.sh --help
set -euo pipefail
# Resolve the directory this script lives in (the cloned tool repo). Uses bash
# builtins only so it works under a minimal PATH.
_src="${BASH_SOURCE[0]}"
TOOL_DIR="$(cd "${_src%/*}" 2>/dev/null && pwd)"
usage() {
cat <<EOF
install.sh — finish setting up the 'secrets' tool.
Run this once after cloning the repo. It verifies dependencies (age, jq, git)
and prints the commands to put 'secrets' on your PATH and onboard a machine.
Usage:
./install.sh Check dependencies and print setup + next steps
./install.sh --help Show this help
It never edits your shell config and never runs sudo — it prints the exact
commands so you stay in control (this is a secrets tool, after all).
Onboarding after setup:
First machine: secrets init --remote <your-private-repo-url>
Other machine: secrets join --remote <your-private-repo-url> --key <key.txt>
EOF
}
# Print the install command for a package, using whatever package manager is
# present. For sudo-requiring managers we PRINT the line for you to run — the
# installer never escalates on its own.
install_hint() {
local pkg="$1"
if command -v brew >/dev/null 2>&1; then
echo "brew install $pkg"
elif command -v apt-get >/dev/null 2>&1; then
echo "sudo apt-get install -y $pkg"
elif command -v dnf >/dev/null 2>&1; then
echo "sudo dnf install -y $pkg"
else
echo "install '$pkg' with your system package manager"
fi
}
case "${1:-}" in
--help|-h) usage; exit 0 ;;
"") ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
echo "secrets — bootstrap check (tool dir: $TOOL_DIR)"
echo ""
# Dependency check. age + jq + git are all load-bearing on the cold-start path:
# jq became required once .secrets.json (manifest) is JSON, so it must be present
# BEFORE the first manifest read.
missing=0
for dep in git age jq; do
if command -v "$dep" >/dev/null 2>&1; then
echo " ok $dep"
else
echo " MISSING $dep — install it with:"
echo " $(install_hint "$dep")"
missing=1
fi
done
echo ""
if [ "$missing" -ne 0 ]; then
echo "Install the missing dependencies above, then re-run ./install.sh." >&2
exit 1
fi
cat <<EOF
All dependencies present. Two steps to finish:
1) Put 'secrets' on your PATH. Add this line to your shell config
(~/.zshrc or ~/.bashrc), then restart your terminal:
export PATH="$TOOL_DIR:\$PATH"
2) Onboard this machine:
First machine (new vault):
secrets init --remote <your-private-repo-url>
# then transfer key.txt to your other machines (AirDrop / scp / USB):
# scp <this-host>:$HOME/.secrets/key.txt ~/.secrets/key.txt
Other machine (join an existing vault):
secrets join --remote <your-private-repo-url> --key <path-to-key.txt>
# 'join' clones the vault, installs the key, and VERIFIES it decrypts
# before declaring success — a mis-copied key fails loudly, not silently.
To update the tool later:
git -C "$TOOL_DIR" pull
EOF

664
secrets
View file

@ -55,6 +55,7 @@ check_cmd() {
check_initialized() { check_initialized() {
if [ -d "$SECRETS_DIR/.git" ]; then if [ -d "$SECRETS_DIR/.git" ]; then
_check_store_version_skew
return return
fi fi
if [ "$STORE_SOURCE" != "default" ]; then if [ "$STORE_SOURCE" != "default" ]; then
@ -624,19 +625,115 @@ _store_format() {
echo 1 echo 1
} }
# The on-disk blob suffix for an external entry, format-aware. v2 unifies # ─── Client/store version skew (EGB-713) ──────────────────────────────
# the legacy `gradle-properties` suffix to `properties` (matching the JSON #
# manifest `type`); `file` is unchanged in both formats. The slug + this # The running client's own version, read from the VERSION file shipped beside
# suffix + `.age` is the external blob name. This is the single source of # the script. Empty/"0.0.0.0" if absent — treated as "unknown/oldest" so a
# truth for the suffix — push, pull, verify all route through it so a v1 # missing VERSION never triggers a spurious nudge.
# and a v2 store can never disagree on where a blob lives. _client_version() {
_external_blob_suffix() { local v=""
local mtype="$1" [ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]')
if [ "$mtype" = "gradle-properties" ] && [ "$(_store_format)" = "2" ]; then printf '%s' "${v:-0.0.0.0}"
echo "properties" }
else
echo "$mtype" # Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2.
# Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0.
_version_gt() {
local a="$1" b="$2" i ai bi; local -a af bf
IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b"
for i in 0 1 2 3; do
ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0
bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0
if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi
if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi
done
return 1
}
WRITER_VERSION_FILE_NAME=".secrets-writer-version"
# Highest client version recorded as having written to the store (empty if the
# store predates this feature — "minus the initial builds", silent by design).
_store_writer_version() {
local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME"
if [ -f "$f" ]; then
head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]'
fi fi
return 0
}
# Raise the store's recorded writer-version to the client's version (monotonic;
# never lowers it). Called right before each store-committing `git add -A` so
# the stamp rides the same commit. Read paths (pull) never call this.
_stamp_writer_version() {
local cur cli
cur=$(_store_writer_version)
cli=$(_client_version)
if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then
printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME"
fi
}
# Warn ONCE per invocation if the store was last written by a newer client than
# us. Non-fatal (read/write paths keep their exit codes). Silent when the store
# carries no writer-version (legacy) or is same/older than us.
_VERSION_SKEW_WARNED=0
_check_store_version_skew() {
[ "$_VERSION_SKEW_WARNED" = 1 ] && return 0
local sv cv
sv=$(_store_writer_version)
[ -n "$sv" ] || return 0
cv=$(_client_version)
if _version_gt "$sv" "$cv"; then
_VERSION_SKEW_WARNED=1
echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2
echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2
fi
return 0
}
# Resolve the on-disk path of an external blob for READING. Tries the v2 suffix
# (.properties.age) first, then falls back to the v1 (.gradle-properties.age) for
# `properties` externals, so an upgraded client finds the blob whichever format
# wrote it (additive v2 — EGB-712). `file` externals share one suffix in both
# formats. Echoes the path of the blob that exists; if neither exists, echoes the
# canonical v2 path so the caller's "no blob" message reads sensibly. Read-only.
_resolve_external_blob_read() {
local project="$1" slug="$2" mtype="$3"
local base="$SECRETS_DIR/$project/external/$slug"
case "$mtype" in
file)
echo "$base.file.age" ;;
properties|gradle-properties)
if [ -f "$base.properties.age" ]; then
echo "$base.properties.age"
elif [ -f "$base.gradle-properties.age" ]; then
echo "$base.gradle-properties.age"
else
echo "$base.properties.age"
fi ;;
*)
echo "$base.$mtype.age" ;;
esac
}
# The on-disk path(s) to WRITE for an external blob, one per line. For a
# `properties` external this is the v2 suffix (.properties.age) ALWAYS, plus the
# v1 suffix (.gradle-properties.age) WHEN a v1 twin already exists in the store
# (dual-write keeps old clients fresh; a brand-new external is v2-only — the
# intended forcing function, additive v2 / EGB-712). `file` externals have a
# single suffix in both formats. Independent of the store marker.
_external_blob_write_targets() {
local project="$1" slug="$2" mtype="$3"
local base="$SECRETS_DIR/$project/external/$slug"
case "$mtype" in
file)
echo "$base.file.age" ;;
properties|gradle-properties)
echo "$base.properties.age"
[ -f "$base.gradle-properties.age" ] && echo "$base.gradle-properties.age" ;;
*)
echo "$base.$mtype.age" ;;
esac
} }
# Merge managed key=value lines (from $2) into target file $1, preserving # Merge managed key=value lines (from $2) into target file $1, preserving
@ -737,7 +834,13 @@ push_external_files() {
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe). # EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
mkdir -p "$SECRETS_DIR/$project/external" mkdir -p "$SECRETS_DIR/$project/external"
local fslug; fslug=$(_secrets_files_slug "$mpath") local fslug; fslug=$(_secrets_files_slug "$mpath")
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded" # EGB-712 dual-write × EGB-283 multi-recipient: write every target
# (v2 + any v1 twin) encrypted to the full recipient set.
local wt
while IFS= read -r wt; do
[ -n "$wt" ] || continue
age "${RECIPIENT_ARGS[@]}" -o "$wt" "$expanded"
done < <(_external_blob_write_targets "$project" "$fslug" file)
info "Encrypted file $mpath" info "Encrypted file $mpath"
pushed=$((pushed + 1)) pushed=$((pushed + 1))
continue continue
@ -766,7 +869,13 @@ push_external_files() {
fi fi
mkdir -p "$SECRETS_DIR/$project/external" mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath") local slug; slug=$(_secrets_files_slug "$mpath")
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp" # EGB-712 dual-write × EGB-283 multi-recipient: write every target
# (v2 + any v1 twin) encrypted to the full recipient set.
local wt
while IFS= read -r wt; do
[ -n "$wt" ] || continue
age "${RECIPIENT_ARGS[@]}" -o "$wt" "$tmp"
done < <(_external_blob_write_targets "$project" "$slug" "$mtype")
rm -f "$tmp" rm -f "$tmp"
info "Extracted $found key(s) from $mpath" info "Extracted $found key(s) from $mpath"
pushed=$((pushed + 1)) pushed=$((pushed + 1))
@ -795,7 +904,7 @@ pull_external_files() {
continue continue
fi fi
local slug; slug=$(_secrets_files_slug "$mpath") local slug; slug=$(_secrets_files_slug "$mpath")
local blob="$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" local blob; blob=$(_resolve_external_blob_read "$project" "$slug" "$mtype")
if [ ! -f "$blob" ]; then if [ ! -f "$blob" ]; then
echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2 echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2
continue continue
@ -1018,6 +1127,31 @@ _json_external_entries() {
done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$manifest") done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$manifest")
} }
# EGB-701 item 2: the read guards for the two external-manifest sources,
# factored out of _external_entries_for_push/_pull so they can't drift.
#
# _json_readable — true when a .secrets.json is a safe regular file to read.
# A symlinked manifest is treated as absent and silently ignored: it's the
# project's own committed file, so a symlink there is just skipped (the fatal
# symlink refusal lives in _check_manifest_file, used by the linting paths).
_json_readable() {
[ -f "$1" ] && [ ! -L "$1" ]
}
# _legacy_readable — true when a legacy .secrets-files is a safe regular file
# to read, warning (and returning false) when it exists but is a symlink: a
# symlinked legacy manifest's target is attacker-influenceable, so never follow
# it. A missing or non-regular file returns false silently.
_legacy_readable() {
local legacy="$1"
[ -e "$legacy" ] || return 1
if [ -L "$legacy" ]; then
echo "WARNING: $legacy is a symlink; ignoring." >&2
return 1
fi
[ -f "$legacy" ]
}
# External tuples for PUSH: .secrets.json entries first, then legacy # External tuples for PUSH: .secrets.json entries first, then legacy
# .secrets-files entries whose (type, path) the manifest doesn't cover — # .secrets-files entries whose (type, path) the manifest doesn't cover —
# the absorb set, which cmd_push folds into the manifest after a # the absorb set, which cmd_push folds into the manifest after a
@ -1026,23 +1160,19 @@ _external_entries_for_push() {
local root="$1" local root="$1"
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME" local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
local seen="" t p k local seen="" t p k
if [ -f "$json" ] && [ ! -L "$json" ]; then if _json_readable "$json"; then
while IFS=$'\t' read -r t p k; do while IFS=$'\t' read -r t p k; do
[ -n "$t" ] || continue [ -n "$t" ] || continue
printf '%s\t%s\t%s\n' "$t" "$p" "$k" printf '%s\t%s\t%s\n' "$t" "$p" "$k"
seen="$seen$t|$p"$'\n' seen="$seen$t|$p"$'\n'
done < <(_json_external_entries "$json") done < <(_json_external_entries "$json")
fi fi
if [ -e "$legacy" ]; then if _legacy_readable "$legacy"; then
if [ -L "$legacy" ]; then while IFS=$'\t' read -r t p k; do
echo "WARNING: $legacy is a symlink; ignoring." >&2 [ -n "$t" ] || continue
elif [ -f "$legacy" ]; then case "$seen" in *"$t|$p"$'\n'*) continue ;; esac
while IFS=$'\t' read -r t p k; do printf '%s\t%s\t%s\n' "$t" "$p" "$k"
[ -n "$t" ] || continue done < <(_parse_secrets_files_manifest "$legacy")
case "$seen" in *"$t|$p"$'\n'*) continue ;; esac
printf '%s\t%s\t%s\n' "$t" "$p" "$k"
done < <(_parse_secrets_files_manifest "$legacy")
fi
fi fi
} }
@ -1051,19 +1181,18 @@ _external_entries_for_push() {
_external_entries_for_pull() { _external_entries_for_pull() {
local root="$1" local root="$1"
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME" local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
if [ -f "$json" ] && [ ! -L "$json" ]; then if _json_readable "$json"; then
if [ -f "$legacy" ] && [ ! -L "$legacy" ]; then # A regular (non-symlink) legacy file alongside the manifest is superseded:
# warn but don't read it. _json_readable is the "plain regular file" test —
# exactly the supersede condition (and unlike _legacy_readable it stays
# silent on a symlink, matching the original no-warn-on-symlink behavior).
if _json_readable "$legacy"; then
echo "WARNING: $legacy is superseded by $SECRETS_JSON_NAME and was ignored on pull. Run 'secrets push' to absorb it, then delete it." >&2 echo "WARNING: $legacy is superseded by $SECRETS_JSON_NAME and was ignored on pull. Run 'secrets push' to absorb it, then delete it." >&2
fi fi
_json_external_entries "$json" _json_external_entries "$json"
return 0 return 0
fi fi
[ -e "$legacy" ] || return 0 _legacy_readable "$legacy" && _parse_secrets_files_manifest "$legacy"
if [ -L "$legacy" ]; then
echo "WARNING: $legacy is a symlink; ignoring." >&2
return 0
fi
[ -f "$legacy" ] && _parse_secrets_files_manifest "$legacy"
return 0 return 0
} }
@ -1215,6 +1344,25 @@ ensure_store_protections() {
cmd_init() { cmd_init() {
check_cmd age check_cmd age
check_cmd git check_cmd git
# EGB-671: flag parsing. --remote wires the encrypted-vault git remote and
# establishes an upstream branch (so the first project push won't hit the
# commit_and_push_secrets `pull --ff-only` die on a brand-new empty remote).
# --yes / non-interactive means init-only: skip the interactive first-add.
local remote="" assume_yes=false
while [ $# -gt 0 ]; do
case "$1" in
--remote) [ $# -ge 2 ] || die "--remote requires a URL"
case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac
remote="$2"; shift 2 ;;
--remote=*) remote="${1#--remote=}"
[ -n "$remote" ] || die "--remote= requires a value"; shift ;;
--yes|-y) assume_yes=true; shift ;;
-*) die "Unknown init flag: $1. Usage: secrets init [--remote <url>] [--yes]" ;;
*) die "Unexpected argument to init: $1" ;;
esac
done
resolve_store resolve_store
if [ -d "$SECRETS_DIR/.git" ]; then if [ -d "$SECRETS_DIR/.git" ]; then
@ -1222,17 +1370,15 @@ cmd_init() {
fi fi
# Second-machine trap: a copied key.txt without a repo means the user # Second-machine trap: a copied key.txt without a repo means the user
# should clone their existing secrets repo, not init a fresh one. # should join their existing vault, not init a fresh one. Catch it BEFORE
# Catch it BEFORE git init so we don't leave a half-initialized store. # git init so we don't leave a half-initialized store.
if [ -f "$KEY_FILE" ]; then if [ -f "$KEY_FILE" ]; then
# Render a runnable clone command when .secrets-store carried a remote
# URL (already sanitized by resolve_store), mirroring check_initialized.
local clone_src="<your-secrets-remote>" local clone_src="<your-secrets-remote>"
[ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL" [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL"
die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR.
If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead: If this is a second machine, don't run 'secrets init' — join your existing vault:
git clone $clone_src $SECRETS_DIR secrets join --remote $clone_src --key $KEY_FILE
Your key file has been left untouched." Your key file has been left untouched."
fi fi
@ -1248,9 +1394,7 @@ Your key file has been left untouched."
# Write .gitignore # Write .gitignore
write_store_gitignore write_store_gitignore
# Stamp the store format (EGB-703): a fresh store is born v2 — it has no # Stamp the store format (EGB-703): a fresh store is born v2.
# v1 blobs, so it is already in v2 shape. The marker is a committed,
# non-secret metadata file (NOT gitignored); the first push stages it.
printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
# Install pre-commit hook # Install pre-commit hook
@ -1267,11 +1411,159 @@ Your key file has been left untouched."
info "Done! Your public key is:" info "Done! Your public key is:"
echo " $pubkey" echo " $pubkey"
if [ -n "$remote" ]; then
_init_wire_remote "$remote"
else
echo ""
echo "Next steps:"
echo " 1. Add a remote: secrets init --remote <url> (or: git -C $SECRETS_DIR remote add origin <url>)"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
echo " 3. On the other machine: secrets join --remote <url> --key <path-to-key.txt>"
fi
# Interactive first-add (EGB-671): only when truly interactive AND not --yes.
# Require BOTH stdin and stdout to be ttys — bats/CI capture a command's
# stdout (so `[ -t 1 ]` is false under automation even when stdin is still
# the terminal), which is the reliable "don't prompt" signal. Default is No.
if [ "$assume_yes" != true ] && [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then
_init_first_add
fi
}
# EGB-671: wire the encrypted-vault remote and establish an upstream branch.
# Commits the born-v2 store (so .secrets-format etc. exist on the remote) and
# push -u, so a later `secrets push` pulls --ff-only against a real upstream
# instead of dying on a non-existent branch.
_init_wire_remote() {
local remote="$1"
git -C "$SECRETS_DIR" remote add origin "$remote"
ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "Initialize secrets store (format v2)" >/dev/null 2>&1 || true
local br
br=$(git -C "$SECRETS_DIR" symbolic-ref --short HEAD 2>/dev/null || echo main)
if git -C "$SECRETS_DIR" push -u origin "$br" >/dev/null 2>&1; then
info "Wired remote origin=$remote and pushed the initial store (upstream: origin/$br)."
else
info "Added remote origin=$remote, but the initial push failed."
echo " Create the PRIVATE repo first, then: git -C $SECRETS_DIR push -u origin $br" >&2
fi
echo "" echo ""
echo "Next steps:" echo "Next: copy $KEY_FILE to your other machine, then run there:"
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>" echo " secrets join --remote $remote --key <path-to-key.txt>"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" }
echo " 3. Run 'secrets push <project>' from a project directory"
# EGB-671: interactive "add your first project" post-step. Reads from /dev/tty
# so it never collides with a piped stdin. Default No. Drives the existing
# push path (cmd_push is $PWD-bound) by cd'ing into the chosen project dir.
_init_first_add() {
printf "Add a project's secrets to the vault now? [y/N] " > /dev/tty 2>/dev/null || return 0
local ans=""
read -r ans < /dev/tty 2>/dev/null || return 0
case "$ans" in [Yy]*) ;; *) return 0 ;; esac
printf "Path to the project directory: " > /dev/tty 2>/dev/null || return 0
local dir=""
read -r dir < /dev/tty 2>/dev/null || return 0
[ -n "$dir" ] || return 0
case "$dir" in
"~") dir="$HOME" ;;
"~/"*) dir="$HOME/${dir#\~/}" ;;
esac
if [ ! -d "$dir" ]; then
echo "Not a directory: $dir — skipping first-add. Run 'secrets push' from a project later." > /dev/tty 2>/dev/null || true
return 0
fi
( cd "$dir" && cmd_push ) || true
return 0
}
# EGB-671: second-machine onboarding. Clone the vault, install the key, and
# decrypt-test it BEFORE declaring success — the one thing a hand-copied
# README sequence never did (a mis-copied key fails silently at first pull).
# Security logic (path rails on --key/--store, URL sanitization) lives in the
# audited core, reused — never re-implemented in a standalone install script.
cmd_join() {
check_cmd age
check_cmd git
local remote="" keyfile=""
while [ $# -gt 0 ]; do
case "$1" in
--remote) [ $# -ge 2 ] || die "--remote requires a URL"
case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac
remote="$2"; shift 2 ;;
--remote=*) remote="${1#--remote=}"
[ -n "$remote" ] || die "--remote= requires a value"; shift ;;
--key) [ $# -ge 2 ] || die "--key requires a path"
case "$2" in --|-*) die "--key value looks like a flag: $2" ;; esac
keyfile="$2"; shift 2 ;;
--key=*) keyfile="${1#--key=}"
[ -n "$keyfile" ] || die "--key= requires a value"; shift ;;
-*) die "Unknown join flag: $1. Usage: secrets join --remote <url> --key <path>" ;;
*) die "Unexpected argument to join: $1" ;;
esac
done
resolve_store
[ -n "$remote" ] || die "secrets join requires --remote <url>
Usage: secrets join --remote <url> --key <path-to-key.txt>"
[ -n "$keyfile" ] || die "secrets join requires --key <path>
This is the age key (key.txt) from your first machine.
Usage: secrets join --remote <url> --key <path-to-key.txt>"
if [ -d "$keyfile" ]; then
die "--key must point to the key FILE, not a directory: $keyfile
Did you mean: --key $keyfile/key.txt ?"
fi
[ -e "$keyfile" ] || die "Key file not found: $keyfile
Copy key.txt from your first machine (AirDrop/scp/USB) and pass its path."
[ -r "$keyfile" ] || die "Key file not readable: $keyfile"
if [ -e "$SECRETS_DIR" ]; then
die "A store already exists at $SECRETS_DIR.
'secrets join' clones a fresh vault — it won't clobber an existing one.
If you meant to refresh it, run 'secrets pull' instead, or remove $SECRETS_DIR first."
fi
info "Joining vault: cloning $remote → $SECRETS_DIR"
if ! git clone "$remote" "$SECRETS_DIR" >/dev/null 2>&1; then
rm -rf "$SECRETS_DIR"
die "Failed to clone $remote
Check the URL and that you have access to the repo."
fi
# Install the key BEFORE anything that decrypts, at mode 600.
cp "$keyfile" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
KEY_FILE="$SECRETS_DIR/key.txt"
ensure_store_protections
if ! get_pubkey >/dev/null 2>&1; then
die "The file you passed to --key is not a valid age identity: $keyfile
Your store was cloned to $SECRETS_DIR; replace key.txt with a valid key and run 'secrets pull'."
fi
# Verify gate: decrypt-test every blob. An EMPTY store returns 0 from
# _verify_all ("nothing to check") — that proves nothing about the key, so
# join must NOT report VERIFIED in that case (EGB-671 / E-S1b).
local blob_count
blob_count=$(find "$SECRETS_DIR" -type f -name '*.age' 2>/dev/null | wc -l | tr -d ' ')
if [ "$blob_count" -eq 0 ]; then
info "Joined $SECRETS_DIR — the vault is empty, so there's nothing to verify yet."
echo "Next: run 'secrets pull' in a project once secrets have been pushed from another machine."
return 0
fi
if _verify_all >/dev/null 2>&1; then
info "VERIFIED — your key decrypts all $blob_count blob(s). You've joined the vault."
echo "Next: run 'secrets pull' in any project to restore its secrets."
return 0
fi
die "Your key does NOT decrypt this vault ($blob_count blob(s) failed).
This is almost always the wrong key.txt. The store is at $SECRETS_DIR;
replace key.txt with the correct key and run 'secrets pull', or remove
$SECRETS_DIR and re-run 'secrets join' with the right --key."
} }
# Encrypt env files from a source dir into a project path in the secrets repo. # Encrypt env files from a source dir into a project path in the secrets repo.
@ -1313,6 +1605,7 @@ commit_and_push_secrets() {
# store missing the key.txt line would stage and push the private key. # store missing the key.txt line would stage and push the private key.
ensure_store_protections ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" add -A
if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
info "No changes to push (secrets unchanged)" info "No changes to push (secrets unchanged)"
@ -1480,8 +1773,22 @@ cmd_push() {
'.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \ '.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \
| _write_manifest_canonical "$manifest" || die "Failed to update $manifest" | _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
else else
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \ # EGB-671 / EGB-677 contract #2: scaffolding the project's FIRST manifest.
'{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \ # Record an explicit, committed options.autoAdd value. Ask once when
# interactive (read from /dev/tty so a piped stdin never collides);
# otherwise write the tool default (ON) explicitly so the value is
# committed and team-shared rather than left implicit.
local autoadd_commit="true"
# Require BOTH stdin and stdout to be ttys (bats/CI capture stdout, so
# `[ -t 1 ]` is false under automation — never block a scripted push).
if [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then
printf "Auto-track new env files in this project as you add them? [Y/n] " > /dev/tty 2>/dev/null || true
local _aa=""
read -r _aa < /dev/tty 2>/dev/null || _aa=""
case "$_aa" in [Nn]*) autoadd_commit="false" ;; *) autoadd_commit="true" ;; esac
fi
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" --argjson autoadd "$autoadd_commit" \
'{version: '"$MANIFEST_VERSION"', dotenv: $add, options: {autoAdd: $autoadd}} | if ($ext | length) > 0 then .external = $ext else . end' \
| _write_manifest_canonical "$manifest" || die "Failed to write $manifest" | _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
fi fi
if [ "$write_adds" = true ]; then if [ "$write_adds" = true ]; then
@ -1597,9 +1904,14 @@ cmd_pull() {
continue continue
fi fi
case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob" # EGB-671 (DX-3): a wrong-but-structurally-valid key must NOT fail
# silently. Die loudly on decrypt failure instead of leaving a partial.
if ! age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"; then
die "Failed to decrypt '$rel' with the current key ($KEY_FILE).
Wrong key for this vault? Run 'secrets verify --all' to check the key."
fi
if [ ! -s "$target_dir/$rel" ]; then if [ ! -s "$target_dir/$rel" ]; then
echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$rel' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done <<< "$declared" done <<< "$declared"
@ -1623,16 +1935,40 @@ cmd_pull() {
local name local name
name=$(basename "$f" .age) name=$(basename "$f" .age)
local outfile="$target_dir/$name" local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f" # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent.
if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then
die "Failed to decrypt '$name' with the current key ($KEY_FILE).
Wrong key for this vault? Run 'secrets verify --all' to check the key."
fi
# Integrity check: verify non-empty # Integrity check: verify non-empty
if [ ! -s "$outfile" ]; then if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done done
info "Decrypted $count file(s) into $target_dir" info "Decrypted $count file(s) into $target_dir"
# EGB-701 item 3: the globs above are non-recursive, so a nested dotenv blob
# (<project>/<relpath>.age) written by a manifest-driven push on another
# machine is invisible here — silently restored nothing, counted nothing.
# external/<slug>.age blobs are restored by pull_external_files, so exclude
# them. Warn (don't die) so a manifest-less pull never under-restores in
# silence; the fix is a committed .secrets.json, which the recursive
# manifest-driven branch above handles correctly.
local nested
nested=$(find "$SECRETS_DIR/$project" -mindepth 2 -type f -name '*.age' \
-not -path "$SECRETS_DIR/$project/external/*" 2>/dev/null)
if [ -n "$nested" ]; then
echo "WARNING: this project has nested encrypted files the manifest-less pull can't restore:" >&2
while IFS= read -r nf; do
[ -n "$nf" ] || continue
local rel="${nf#"$SECRETS_DIR/$project/"}"
echo " ${rel%.age}" >&2
done <<< "$nested"
echo " Add a $SECRETS_JSON_NAME manifest (run 'secrets push' on a machine that has these files) so they restore." >&2
fi
# Merge any external files (.secrets-files) declared in this project. # Merge any external files (.secrets-files) declared in this project.
pull_external_files "$PWD" "$project" pull_external_files "$PWD" "$project"
@ -1655,9 +1991,13 @@ pull_project_to_dir() {
local name local name
name=$(basename "$f" .age) name=$(basename "$f" .age)
local outfile="$target_dir/$name" local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f" # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent.
if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then
die "Failed to decrypt '$name' with the current key ($KEY_FILE).
Wrong key for this vault? Run 'secrets verify --all' to check the key."
fi
if [ ! -s "$outfile" ]; then if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done done
@ -1725,9 +2065,21 @@ cmd_pull_workspaces() {
} }
cmd_list() { cmd_list() {
local json=0
case "${1:-}" in
--json) json=1 ;;
"") ;;
*) die "Usage: secrets list [--json]" ;;
esac
resolve_store resolve_store
check_initialized check_initialized
if [ "$json" -eq 1 ]; then
cmd_list_json
return
fi
local found=0 local found=0
for dir in "$SECRETS_DIR"/*/; do for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
@ -1763,6 +2115,64 @@ cmd_list() {
fi fi
} }
# EGB-699: machine-readable listing for tooling/CI (feeds EGB-671 install
# scripts). Contract: a single JSON object on stdout —
# {"store": "<dir>", "projects": [{"name", "entries": [...]}]}
# where each entry is {"type":"dotenv","path":<relpath>} or
# {"type":"external","subtype":"properties"|"file","path":<slug>}. Mirrors the
# recursive store walk the human `list` uses (nested <project>/<relpath>.age +
# external/<slug>.age). jq does the assembly so paths are escaped correctly;
# stdout stays pure JSON (the human store hint is suppressed in this mode).
cmd_list_json() {
check_cmd jq
{
for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue
local project
project=$(basename "$dir")
[[ "$project" == .* ]] && continue
# Marker line so a project with zero blobs still appears (mirrors the
# human header), grouped via jq below.
printf 'project\t%s\n' "$project"
local f rel name
while IFS= read -r f; do
[ -f "$f" ] || continue
rel=${f#"$dir"}
rel=${rel%.age}
case "$rel" in
external/*)
name=${rel#external/}
case "$name" in
*.file) printf 'entry\t%s\texternal\tfile\t%s\n' "$project" "${name%.file}" ;;
*.properties) printf 'entry\t%s\texternal\tproperties\t%s\n' "$project" "${name%.properties}" ;;
*.gradle-properties) printf 'entry\t%s\texternal\tproperties\t%s\n' "$project" "${name%.gradle-properties}" ;;
*) printf 'entry\t%s\texternal\tunknown\t%s\n' "$project" "$name" ;;
esac
;;
*)
printf 'entry\t%s\tdotenv\t\t%s\n' "$project" "$rel"
;;
esac
done < <(find "$dir" -type f -name '*.age' | sort)
done
} | jq -R -n --arg store "$SECRETS_DIR" '
[inputs | split("\t")] as $lines
| ($lines | map(select(.[0] == "project") | .[1]) | unique) as $names
| {
store: $store,
projects: ($names | map(. as $p | {
name: $p,
entries: [ $lines[]
| select(.[0] == "entry" and .[1] == $p)
| if .[2] == "external"
then { type: "external", subtype: .[3], path: .[4] }
else { type: "dotenv", path: .[4] }
end ]
}))
}'
}
cmd_rm() { cmd_rm() {
check_cmd git check_cmd git
resolve_store resolve_store
@ -1955,6 +2365,7 @@ cmd_rekey() {
# Commit and push (heal .gitignore first so add -A can't stage key.txt) # Commit and push (heal .gitignore first so add -A can't stage key.txt)
ensure_store_protections ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
@ -2210,6 +2621,15 @@ cmd_which() {
# EGB-700 (folded into EGB-703): surface the store format so users can tell # EGB-700 (folded into EGB-703): surface the store format so users can tell
# v1 from v2 during the migration window. v1 = legacy store, no format marker. # v1 from v2 during the migration window. v1 = legacy store, no format marker.
echo "format: v$(_store_format)" echo "format: v$(_store_format)"
local _wv; _wv=$(_store_writer_version)
if [ -n "$_wv" ]; then
local _cv; _cv=$(_client_version)
if _version_gt "$_wv" "$_cv"; then
echo "written-by: v$_wv (you're on v$_cv — behind; run: git -C \"$SCRIPT_DIR\" pull)"
else
echo "written-by: v$_wv"
fi
fi
# EGB-283: surface the recipient set (store-scoped; one key per team member). # EGB-283: surface the recipient set (store-scoped; one key per team member).
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
@ -2246,11 +2666,15 @@ cmd_which() {
echo " dotenv $entry [UNSAFE — will be refused]" echo " dotenv $entry [UNSAFE — will be refused]"
fi fi
done < <(jq -r '.dotenv // [] | .[]' "$json_manifest") done < <(jq -r '.dotenv // [] | .[]' "$json_manifest")
# EGB-701 item 1: reuse the one external extractor the sync path uses,
# so `which` applies the same normalization + skip-with-warning rules
# push/pull do — `which` shows exactly what will sync, never a stale
# raw projection that drifts from the helper.
local etype epath ekeys local etype epath ekeys
while IFS=$'\t' read -r etype epath ekeys; do while IFS=$'\t' read -r etype epath ekeys; do
[ -n "$etype" ] || continue [ -n "$etype" ] || continue
echo " $etype $epath $ekeys" echo " $etype $epath $ekeys"
done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$json_manifest") done < <(_json_external_entries "$json_manifest")
fi fi
# Read back any external-file manifest in cwd (validates the format and # Read back any external-file manifest in cwd (validates the format and
@ -2270,6 +2694,90 @@ cmd_which() {
fi fi
} }
# `secrets upgrade [--check]` (EGB-716) — self-update the TOOL checkout.
#
# Pairs the EGB-713 skew WARNING with a fix path. Deliberately thin and explicit
# (no auto-update, no background polling — this is a security tool): it only
# fast-forwards the tool's own git checkout, never merges or rewrites local
# commits. --check reports whether an update is available and changes nothing.
# After a real update it best-effort re-checks the store's writer-version skew
# against the NEW on-disk version, so the operator sees whether the EGB-713
# nudge is now cleared (the new code itself takes effect on the next command).
cmd_upgrade() {
local check_only=0
while [ $# -gt 0 ]; do
case "$1" in
--check) check_only=1; shift ;;
-*) die "Unknown upgrade flag: $1. Usage: secrets upgrade [--check]" ;;
*) die "Unexpected argument to upgrade: $1. Usage: secrets upgrade [--check]" ;;
esac
done
check_cmd git
# Self-update only works on a git checkout of the tool.
if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
die "secrets at $SCRIPT_DIR is not a git checkout, so it can't self-update.
Re-install by cloning the tool repo, e.g.: git clone <tool-remote> <dir>"
fi
# Need a tracking branch to compare against / pull from.
local upstream
upstream=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)
if [ -z "$upstream" ]; then
die "No upstream tracking branch for the secrets checkout at $SCRIPT_DIR.
Set one with: git -C \"$SCRIPT_DIR\" branch --set-upstream-to=origin/main"
fi
local oldver; oldver=$(_client_version)
if [ "$check_only" = 1 ]; then
if ! git -C "$SCRIPT_DIR" fetch --quiet 2>/dev/null; then
die "Couldn't reach the tool remote to check for updates (offline?).
Try again when connected, or run: git -C \"$SCRIPT_DIR\" fetch"
fi
local behind; behind=$(git -C "$SCRIPT_DIR" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0)
if [ "${behind:-0}" -gt 0 ]; then
info "Update available: $behind commit(s) behind $upstream (you're on v$oldver)."
info "Apply it with: secrets upgrade"
else
info "secrets is up to date (v$oldver)."
fi
return 0
fi
info "Updating secrets at $SCRIPT_DIR ..."
# Fast-forward only: never merge or rewrite local commits.
if ! git -C "$SCRIPT_DIR" pull --ff-only 2>&1; then
die "Update failed (see git output above).
Likely a local change or a diverged branch in $SCRIPT_DIR.
Inspect with: git -C \"$SCRIPT_DIR\" status"
fi
local newver; newver=$(_client_version)
if [ "$oldver" = "$newver" ]; then
info "Already up to date (v$newver)."
else
info "Upgraded: v$oldver -> v$newver"
info "The new version takes effect on your next 'secrets' command."
fi
# Best-effort EGB-713 skew re-check against the NEW version. Silent unless a
# store with a recorded writer-version resolves.
resolve_store 2>/dev/null || true
if [ -n "${SECRETS_DIR:-}" ] && [ -f "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" ]; then
local sv; sv=$(_store_writer_version)
if [ -n "$sv" ]; then
if _version_gt "$sv" "$newver"; then
info "Note: the store was last written by v$sv — still ahead of v$newver. Another machine may run a newer client."
else
info "Your client (v$newver) is now at or ahead of the store's last writer (v$sv)."
fi
fi
fi
return 0
}
# Decrypt-test one blob with the current key. Plaintext is streamed to # Decrypt-test one blob with the current key. Plaintext is streamed to
# /dev/null and never written to disk (read-only contract). Returns 0 if the # /dev/null and never written to disk (read-only contract). Returns 0 if the
# blob decrypts, non-zero otherwise. # blob decrypts, non-zero otherwise.
@ -2418,14 +2926,13 @@ _verify_project() {
while IFS=$'\t' read -r etype epath _; do while IFS=$'\t' read -r etype epath _; do
[ -n "$etype" ] || continue [ -n "$etype" ] || continue
slug=$(_secrets_files_slug "$epath") slug=$(_secrets_files_slug "$epath")
erel="external/$slug.$(_external_blob_suffix "$etype").age" # Account for BOTH suffix forms in the orphan set — a dual-written `properties`
# Account for BOTH the v1 and v2 suffix forms in the orphan set. During the # external (additive v2 — EGB-712) legitimately has both blobs on disk; neither
# migration window (after copy-forward, before --finalize) the v2 twin # is an orphan. (file's two forms are identical.)
# coexists with the v1 blob; neither should read as an orphan whichever
# format the store currently reports. (file's two forms are identical.)
expected="${expected}external/$slug.$etype.age"$'\n' expected="${expected}external/$slug.$etype.age"$'\n'
[ "$etype" = "gradle-properties" ] && expected="${expected}external/$slug.properties.age"$'\n' [ "$etype" = "gradle-properties" ] && expected="${expected}external/$slug.properties.age"$'\n'
eblob="$pdir/$erel" eblob=$(_resolve_external_blob_read "$project" "$slug" "$etype")
erel="${eblob#"$pdir"/}"
if [ ! -f "$eblob" ]; then if [ ! -f "$eblob" ]; then
echo "FINDING: external '$epath' ($etype) is declared but has no blob in the store ($project/$erel missing). Run 'secrets push'." >&2 echo "FINDING: external '$epath' ($etype) is declared but has no blob in the store ($project/$erel missing). Run 'secrets push'." >&2
findings=$((findings + 1)) findings=$((findings + 1))
@ -2546,6 +3053,7 @@ _migrate_project() {
return 0 return 0
fi fi
ensure_store_protections ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "migrate: copy-forward v2 twins for $project" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" commit -m "migrate: copy-forward v2 twins for $project" >/dev/null 2>&1 || true
# Push the twins so a --finalize on another machine sees them (finalize # Push the twins so a --finalize on another machine sees them (finalize
@ -2576,12 +3084,21 @@ _migrate_status() {
new="${f%.gradle-properties.age}.properties.age" new="${f%.gradle-properties.age}.properties.age"
[ -f "$new" ] || untwinned=$((untwinned + 1)) [ -f "$new" ] || untwinned=$((untwinned + 1))
done < <(find "$dir" -type f -name '*.gradle-properties.age' 2>/dev/null) done < <(find "$dir" -type f -name '*.gradle-properties.age' 2>/dev/null)
# v2-only externals: a .properties.age with no .gradle-properties.age twin —
# old (v1) clients cannot read these (the additive-v2 forcing function).
local v2only=0 pf
while IFS= read -r pf; do
[ -f "$pf" ] || continue
[ -f "${pf%.properties.age}.gradle-properties.age" ] || v2only=$((v2only + 1))
done < <(find "$dir" -type f -name '*.properties.age' 2>/dev/null)
local v2note=""
[ "$v2only" -gt 0 ] && v2note=" [$v2only v2-only — old clients not served]"
if [ "$v1" -eq 0 ]; then if [ "$v1" -eq 0 ]; then
echo " $project: v2-ready (no v1 properties blobs)" echo " $project: v2-ready (no v1 properties blobs)$v2note"
elif [ "$untwinned" -eq 0 ]; then elif [ "$untwinned" -eq 0 ]; then
echo " $project: migrated ($v1 v1 blob(s), all twinned)" echo " $project: migrated ($v1 v1 blob(s), all twinned)$v2note"
else else
echo " $project: NEEDS MIGRATE ($untwinned of $v1 v1 blob(s) un-twinned) — cd into the project and run 'secrets migrate'" echo " $project: NEEDS MIGRATE ($untwinned of $v1 v1 blob(s) un-twinned) — cd into the project and run 'secrets migrate'$v2note"
any_untwinned=1 any_untwinned=1
fi fi
done done
@ -2627,6 +3144,7 @@ $untwinned cd into each project and run 'secrets migrate', then re-run 'secrets
# No v1 blobs at all — just stamp the marker (dotenv/file-only store). # No v1 blobs at all — just stamp the marker (dotenv/file-only store).
printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
ensure_store_protections ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2" >/dev/null 2>&1 || true
git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true
@ -2662,6 +3180,7 @@ $untwinned cd into each project and run 'secrets migrate', then re-run 'secrets
done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age') done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age')
ensure_store_protections ensure_store_protections
_stamp_writer_version
git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2 (drop $v1count v1 blob(s))" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2 (drop $v1count v1 blob(s))" >/dev/null 2>&1 || true
git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true
@ -2697,6 +3216,10 @@ secrets — encrypted secret file sync between machines
Usage: Usage:
secrets init Initialize the secrets repo and generate an age key secrets init Initialize the secrets repo and generate an age key
secrets init --remote <url> Init, wire the remote, and push the initial store
secrets join --remote <url> --key <path>
Join an existing vault on a new machine: clone,
install the key, and verify it decrypts the store
secrets push [project] Encrypt secret files and push to the secrets repo 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 --frozen Sync only manifest-declared files (skip auto-add)
secrets push --dry-run Show what would be added/synced; change nothing secrets push --dry-run Show what would be added/synced; change nothing
@ -2708,13 +3231,14 @@ Usage:
secrets clear -w|--workspaces Clear secrets from all workspaces in package.json 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 run [-w] <command> Pull secrets, run command, clear secrets on exit
secrets list List all projects and their secret files secrets list List all projects and their secret files
secrets list --json Same listing as machine-readable JSON (for tooling/CI)
secrets rm <project> Remove a project's secrets from the repo secrets rm <project> Remove a project's secrets from the repo
secrets rekey Re-encrypt all secrets with a new key secrets rekey Re-encrypt all secrets with a new key
secrets verify [project] Check the manifest against the store + decrypt every blob secrets verify [project] Check the manifest against the store + decrypt every blob
secrets verify --all Decrypt-test every blob in every project (integrity gate) secrets verify --all Decrypt-test every blob in every project (integrity gate)
secrets migrate [--dry-run] Copy-forward this project's v1 blobs to store format v2 secrets migrate [--dry-run] Copy-forward this project's v1 blobs to store format v2
secrets migrate --status Survey every project's v2 readiness (finalize gate) secrets migrate --status Survey every project's v2 readiness (finalize gate)
secrets migrate --finalize Drop v1 blobs and mark the store v2 (after verify) secrets migrate --finalize Optional GC: drop v1 blobs and mark the store pure v2
secrets recipients list List the store's recipient keys secrets recipients list List the store's recipient keys
secrets recipients add KEY [--name N] Add a recipient and re-encrypt the store secrets recipients add KEY [--name N] Add a recipient and re-encrypt the store
secrets recipients rm KEY|NAME [--yes] Remove a recipient and re-encrypt the store secrets recipients rm KEY|NAME [--yes] Remove a recipient and re-encrypt the store
@ -2722,6 +3246,8 @@ Usage:
secrets which Show the active store, manifest, and external entries secrets which Show the active store, manifest, and external entries
secrets where Alias for `which` secrets where Alias for `which`
secrets status Alias for `which` secrets status Alias for `which`
secrets upgrade Self-update the tool (git pull --ff-only) + recheck skew
secrets upgrade --check Report whether an update is available; change nothing
Tracked files: .env, .env.*, .dev.vars Tracked files: .env, .env.*, .dev.vars
@ -2856,7 +3382,8 @@ else
fi fi
case "${1:-help}" in case "${1:-help}" in
init) cmd_init ;; init) shift; cmd_init "$@" ;;
join) shift; cmd_join "$@" ;;
push) push)
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
cmd_push_workspaces cmd_push_workspaces
@ -2884,7 +3411,7 @@ case "${1:-help}" in
cmd_run "$@" cmd_run "$@"
;; ;;
add) cmd_add "${2:-}" ;; add) cmd_add "${2:-}" ;;
list) cmd_list ;; list) shift; cmd_list "$@" ;;
rm) cmd_rm "${2:-}" ;; rm) cmd_rm "${2:-}" ;;
rekey) cmd_rekey ;; rekey) cmd_rekey ;;
reencrypt) cmd_reencrypt ;; reencrypt) cmd_reencrypt ;;
@ -2892,6 +3419,7 @@ case "${1:-help}" in
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;
recipients) shift; cmd_recipients "$@" ;; recipients) shift; cmd_recipients "$@" ;;
which|where|status) cmd_which ;; which|where|status) cmd_which ;;
upgrade) shift; cmd_upgrade "$@" ;;
help|--help|-h) cmd_help ;; help|--help|-h) cmd_help ;;
*) die "Unknown command: $1. Run 'secrets help' for usage." ;; *) die "Unknown command: $1. Run 'secrets help' for usage." ;;
esac esac

72
test/install.bats Normal file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bats
# EGB-671: install.sh thin bootstrap. It ships IN the repo (you clone the repo
# to get it), so its job is: verify deps (age + jq + git), PRINT the PATH line
# and next-step commands — never edit dotfiles, never invoke sudo. Security-rail
# concerns are operator-local (.ship-policy.json); these are functional checks.
load test_helper
INSTALL_SH="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)/install.sh"
@test "install.sh exists and is executable" {
[ -f "$INSTALL_SH" ]
[ -x "$INSTALL_SH" ]
}
@test "install.sh --help prints usage and exits 0" {
run "$INSTALL_SH" --help
[ "$status" -eq 0 ]
[[ "$output" == *"install.sh"* ]] || false
[[ "$output" == *"join"* ]] || false
}
@test "install.sh prints the PATH export line for the tool dir (does not edit rc)" {
local tool_dir
tool_dir="$(cd "$(dirname "$INSTALL_SH")" && pwd)"
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"export PATH="* ]] || false
[[ "$output" == *"$tool_dir"* ]] || false
# It must NOT have written to any shell rc in the isolated HOME.
[ ! -f "$HOME/.zshrc" ]
[ ! -f "$HOME/.bashrc" ]
}
@test "install.sh prints both onboarding next-steps (init --remote and join)" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"secrets init --remote"* ]] || false
[[ "$output" == *"secrets join --remote"* ]] || false
}
@test "install.sh prints the upgrade one-liner" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"git -C"* ]] || false
[[ "$output" == *"pull"* ]] || false
}
@test "install.sh prints a key-transfer hint" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"key.txt"* ]] || false
}
@test "install.sh never invokes sudo (prints it for the user instead)" {
# No executed 'sudo' — any sudo reference must be quoted guidance text.
run grep -nE '^[[:space:]]*sudo ' "$INSTALL_SH"
[ "$status" -ne 0 ]
}
@test "install.sh reports a missing dependency with an install hint and non-zero exit" {
# Build a minimal PATH that has the tools install.sh needs but NOT jq.
local fake="$TEST_TMPDIR/fakebin"
mkdir -p "$fake"
for t in bash uname env cat grep sed tr dirname command age git printf; do
src="$(command -v "$t" 2>/dev/null || true)"
[ -n "$src" ] && ln -sf "$src" "$fake/$t" 2>/dev/null || true
done
run env PATH="$fake" "$INSTALL_SH"
[ "$status" -ne 0 ]
[[ "$output" == *"jq"* ]] || false
}

145
test/join.bats Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env bats
# EGB-671: `secrets join` (second-machine onboarding) + `secrets init --remote`
# + day-2 silent-decrypt fix. Functional paths only — security-rail tests
# (path traversal on --key/--store, URL injection) are operator-local per
# .ship-policy.json and live in test/run-security.sh.
load test_helper
# Push a project to REMOTE_DIR and save the key, then remove the local store
# to simulate a fresh second machine. Leaves: REMOTE_DIR has blobs,
# $TEST_TMPDIR/saved-key.txt is the decrypting key, $SECRETS_DIR is gone.
_machine1_push_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
create_project_dir "joinproj"
"$SECRETS_BIN" push >/dev/null 2>&1
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# Like above but never pushes a project — remote has a store with zero blobs.
_machine1_empty_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# ─── secrets join ────────────────────────────────────────────────────────
@test "join without --remote fails with usage" {
run "$SECRETS_BIN" join
[ "$status" -ne 0 ]
[[ "$output" == *"--remote"* ]] || false
}
@test "join clones the store, installs the key at 600, verifies, and succeeds" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"VERIFIED"* ]] || false
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
# key installed at mode 600
local perms
perms=$(stat -f '%Lp' "$SECRETS_DIR/key.txt" 2>/dev/null || stat -c '%a' "$SECRETS_DIR/key.txt")
[ "$perms" = "600" ]
}
@test "join with the wrong key fails loudly and does not report VERIFIED" {
_machine1_push_then_wipe
age-keygen -o "$TEST_TMPDIR/wrong-key.txt" 2>/dev/null
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/wrong-key.txt"
[ "$status" -ne 0 ]
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join against an empty store reports nothing-to-verify, NOT VERIFIED" {
_machine1_empty_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing to verify"* ]] || false
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join refuses when a store already exists at the target" {
"$SECRETS_BIN" init >/dev/null 2>&1
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"already"* ]] || false
}
@test "join fails clearly when the key file is missing" {
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/nope.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
@test "join detects a directory passed as --key" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
# ─── secrets init --remote ────────────────────────────────────────────────
@test "init --remote sets origin and establishes an upstream branch" {
run "$SECRETS_BIN" init --remote "$REMOTE_DIR"
[ "$status" -eq 0 ]
run git -C "$SECRETS_DIR" remote get-url origin
[ "$status" -eq 0 ]
[ "$output" = "$REMOTE_DIR" ]
# upstream branch exists on the remote (so a later push won't ff-only die)
run git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}'
[ "$status" -eq 0 ]
}
@test "init --remote then push does not die on the brand-new remote" {
"$SECRETS_BIN" init --remote "$REMOTE_DIR" >/dev/null 2>&1
create_project_dir "freshproj"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"Fast-forward pull failed"* ]] || false
}
@test "init with no flags still works (clean primitive)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/key.txt" ]
}
@test "init does not hang on the first-add prompt when stdin is a tty but stdout is captured" {
# Regression: run-security.sh runs bats in a real terminal, so the command's
# stdin stays a tty while bats captures its stdout. The interactive first-add
# prompt must NOT fire in that shape (it gates on stdout being a tty too),
# or the whole suite hangs. Reproduce with a pty via `script`.
command -v script >/dev/null 2>&1 || skip "script (pty) not available"
# macOS/BSD syntax: `script -q <file> <cmd...>`. Skip on other syntaxes.
script -q /dev/null true >/dev/null 2>&1 || skip "unsupported script syntax"
local out="$TEST_TMPDIR/pty-initout"
run timeout 10 script -q /dev/null bash -c "'$SECRETS_BIN' init > '$out' 2>&1"
[ "$status" -ne 124 ] # 124 == timeout == it hung on a prompt
run grep -c "Add a project's secrets" "$out"
[ "$output" = "0" ]
}
# ─── day-2 silent-decrypt fix ─────────────────────────────────────────────
@test "pull dies loudly when a blob cannot be decrypted with the current key" {
init_with_remote
create_project_dir "decryptproj"
"$SECRETS_BIN" push >/dev/null 2>&1
# Swap in a different key so the stored blob no longer decrypts.
# (age-keygen refuses to overwrite, so generate elsewhere then copy.)
age-keygen -o "$TEST_TMPDIR/other-key.txt" 2>/dev/null
cp "$TEST_TMPDIR/other-key.txt" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
cd "$WORK_DIR/decryptproj"
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"decrypt"* ]] || false
}

View file

@ -167,6 +167,17 @@ load test_helper
[ "$output" = "2" ] [ "$output" = "2" ]
} }
@test "bootstrap: first push writes an explicit options.autoAdd value (EGB-677 contract #2)" {
init_with_remote
create_project_dir autoaddproj
# Non-interactive (bats has no tty): the prompt is skipped and the tool
# default (ON) is written explicitly so the value is committed + team-shared.
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.options.autoAdd' .secrets.json
[ "$output" = "true" ]
}
@test "failed push leaves no bootstrap manifest behind" { @test "failed push leaves no bootstrap manifest behind" {
init_with_remote init_with_remote
mkdir -p "$WORK_DIR/emptyproj" mkdir -p "$WORK_DIR/emptyproj"
@ -631,6 +642,31 @@ m_nojq_path() {
[[ "$output" == *"k1"* ]] || false [[ "$output" == *"k1"* ]] || false
} }
# EGB-701 item 1: `which` and the push/pull external extractor share one
# helper (_json_external_entries), so `which` applies the same
# properties→gradle-properties normalization the sync path uses — no drift.
@test "which normalizes a properties external to the gradle-properties token (EGB-701)" {
create_project_dir whichnorm
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["k1"]}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"gradle-properties"* ]] || false
}
# EGB-701 item 1: a malformed external (a properties entry with no keys) is
# skipped by the sync path; routing `which` through the shared extractor means
# `which` skips+warns it too, so it faithfully shows what actually syncs
# rather than printing an entry push/pull silently drop.
@test "which skips a malformed external entry the sync path would drop (EGB-701)" {
create_project_dir whichmalformed
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties"}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"has no keys"* ]] || false
# The skipped entry's path must NOT appear in the printed manifest summary.
[[ "$output" != *" gradle-properties ~/.gradle/gradle.properties"* ]] || false
}
# ─── F: ship Step 7 coverage backfill (audit gaps) ───────────────────── # ─── F: ship Step 7 coverage backfill (audit gaps) ─────────────────────
@test "which flags an unsafe dotenv entry with the UNSAFE marker" { @test "which flags an unsafe dotenv entry with the UNSAFE marker" {
@ -728,6 +764,46 @@ m_nojq_path() {
[ "$(cat packages/web/.env.development)" = "N=nested" ] [ "$(cat packages/web/.env.development)" = "N=nested" ]
} }
@test "legacy (manifest-less) pull warns about nested blobs it can't restore (EGB-701)" {
# The legacy pull path globs only top-level *.age/.*.age. A nested dotenv
# blob (<project>/<relpath>.age) written by a manifest-driven push on another
# machine is invisible to those globs — restored nothing, counted nothing.
# The fix: warn so a manifest-less pull never silently under-restores.
init_with_remote
create_project_dir nestlegacy
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
[ -f "$SECRETS_DIR/nestlegacy/packages/web/.env.development.age" ]
# Simulate a machine with no manifest: drop .secrets.json + local files,
# forcing the legacy non-recursive glob branch.
rm -f .secrets.json
rm -rf packages
run "$SECRETS_BIN" pull nestlegacy
[ "$status" -eq 0 ]
# The warning names the nested blob and points at the manifest as the fix.
[[ "$output" == *"packages/web/.env.development"* ]] || false
[[ "$output" == *"$SECRETS_JSON_NAME"* || "$output" == *".secrets.json"* ]] || false
# The legacy path genuinely can't restore it (the warning is the contract).
[ ! -f packages/web/.env.development ]
}
@test "legacy pull does NOT warn about external/ blobs (handled separately, EGB-701)" {
# external/<slug>.age blobs are restored by pull_external_files, not the
# dotenv globs, so they must not trip the nested-blob warning.
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir extnolwarn
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push >/dev/null 2>&1
[ -d "$SECRETS_DIR/extnolwarn/external" ]
run "$SECRETS_BIN" pull extnolwarn
[ "$status" -eq 0 ]
[[ "$output" != *"can't restore"* ]] || false
[[ "$output" != *"nested encrypted"* ]] || false
}
@test "list shows a nested manifest blob" { @test "list shows a nested manifest blob" {
init_with_remote init_with_remote
create_project_dir nestlist create_project_dir nestlist

View file

@ -10,6 +10,19 @@ make_v1_store() {
init_with_remote init_with_remote
rm -f "$SECRETS_DIR/.secrets-format" rm -f "$SECRETS_DIR/.secrets-format"
} }
# Simulate an old (v1) client's properties blob: copy the pushed v2
# .properties.age to its v1 .gradle-properties.age twin (KEEPS both present).
m_fake_v1_twin() {
local proj="$1" v2
v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age)
cp "$v2" "${v2%.properties.age}.gradle-properties.age"
}
# Like m_fake_v1_twin but RENAMES (leaves ONLY the v1 blob) — for copy-forward fixtures.
m_make_v1_only() {
local proj="$1" v2
v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age)
mv "$v2" "${v2%.properties.age}.gradle-properties.age"
}
m_gradle_src() { mkdir -p "$HOME/.gradle"; printf '%s' "$1" > "$HOME/.gradle/gradle.properties"; } m_gradle_src() { mkdir -p "$HOME/.gradle"; printf '%s' "$1" > "$HOME/.gradle/gradle.properties"; }
m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' > "$HOME/keystores/upload.keystore"; } m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' > "$HOME/keystores/upload.keystore"; }
@ -49,13 +62,41 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
} }
@test "push on a v1 store still writes .gradle-properties.age (back-compat)" { @test "push on a v1 store writes the v2 suffix for a fresh external (additive v2)" {
make_v1_store make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n' m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir v1push create_project_dir v1push
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push v1push >/dev/null 2>&1 "$SECRETS_BIN" push v1push >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/v1push/external/*.gradle-properties.age" run bash -c "ls $SECRETS_DIR/v1push/external/*.properties.age"
[ "$status" -eq 0 ]
}
@test "push writes the v2 suffix for a fresh external even on a v1 store" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir freshv1
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push freshv1 >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/freshv1/external/*.properties.age"
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/freshv1/external/*.gradle-properties.age 2>/dev/null"
[ "$status" -ne 0 ]
}
@test "push dual-writes the v1 twin so old clients stay fresh" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_old\n'
create_project_dir dualwrite
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push dualwrite >/dev/null 2>&1
m_fake_v1_twin dualwrite
m_gradle_src $'beaconClerkPkTest=pk_test_new\n'
"$SECRETS_BIN" push dualwrite >/dev/null 2>&1
rm -f "$SECRETS_DIR/dualwrite/external/"*.properties.age
rm -f "$HOME/.gradle/gradle.properties"
"$SECRETS_BIN" pull dualwrite >/dev/null 2>&1
run grep -q 'beaconClerkPkTest=pk_test_new' "$HOME/.gradle/gradle.properties"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@ -67,6 +108,21 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "pull reads a v1-suffix properties blob on a v2 store (read-fallback)" {
init_with_remote # born-v2 store (marker=2)
m_gradle_src $'beaconClerkPkTest=pk_test_v1\n'
create_project_dir rffallback
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push rffallback >/dev/null 2>&1 # writes .properties.age on a v2 store
# Simulate an external that exists only in the v1 suffix (an old client wrote it):
local v2blob; v2blob=$(ls "$SECRETS_DIR/rffallback/external/"*.properties.age)
mv "$v2blob" "${v2blob%.properties.age}.gradle-properties.age"
rm -f "$HOME/.gradle/gradle.properties"
"$SECRETS_BIN" pull rffallback >/dev/null 2>&1
run grep -q 'beaconClerkPkTest=pk_test_v1' "$HOME/.gradle/gradle.properties"
[ "$status" -eq 0 ]
}
# ─── migrate --dry-run / copy-forward (increment 2) ─────────────────── # ─── migrate --dry-run / copy-forward (increment 2) ───────────────────
@test "migrate --dry-run reports the rename and writes nothing" { @test "migrate --dry-run reports the rename and writes nothing" {
@ -75,6 +131,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir dryproj create_project_dir dryproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push dryproj >/dev/null 2>&1 "$SECRETS_BIN" push dryproj >/dev/null 2>&1
m_make_v1_only dryproj
run "$SECRETS_BIN" migrate --dry-run run "$SECRETS_BIN" migrate --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"would migrate"* ]] || false [[ "$output" == *"would migrate"* ]] || false
@ -100,6 +157,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir cfproj create_project_dir cfproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push cfproj >/dev/null 2>&1 "$SECRETS_BIN" push cfproj >/dev/null 2>&1
m_make_v1_only cfproj
local old; old=$(ls "$SECRETS_DIR/cfproj/external/"*.gradle-properties.age) local old; old=$(ls "$SECRETS_DIR/cfproj/external/"*.gradle-properties.age)
run "$SECRETS_BIN" migrate run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
@ -115,6 +173,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir idemproj create_project_dir idemproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push idemproj >/dev/null 2>&1 "$SECRETS_BIN" push idemproj >/dev/null 2>&1
m_make_v1_only idemproj
"$SECRETS_BIN" migrate >/dev/null 2>&1 "$SECRETS_BIN" migrate >/dev/null 2>&1
run "$SECRETS_BIN" migrate run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
@ -131,6 +190,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir nomanifestblob create_project_dir nomanifestblob
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push nomanifestblob >/dev/null 2>&1 "$SECRETS_BIN" push nomanifestblob >/dev/null 2>&1
m_make_v1_only nomanifestblob
rm -f .secrets.json # simulate a pre-manifest project rm -f .secrets.json # simulate a pre-manifest project
run "$SECRETS_BIN" migrate run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
@ -146,6 +206,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir staleblob create_project_dir staleblob
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push staleblob >/dev/null 2>&1 "$SECRETS_BIN" push staleblob >/dev/null 2>&1
m_make_v1_only staleblob
# The blob is now in the store. Drop the external from the project's manifest # The blob is now in the store. Drop the external from the project's manifest
# entirely (and remove the legacy file) so NO manifest declares it. # entirely (and remove the legacy file) so NO manifest declares it.
printf '{"version":2,"dotenv":[".env",".env.staging"]}\n' > .secrets.json printf '{"version":2,"dotenv":[".env",".env.staging"]}\n' > .secrets.json
@ -206,6 +267,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir failverify create_project_dir failverify
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push failverify >/dev/null 2>&1 "$SECRETS_BIN" push failverify >/dev/null 2>&1
m_make_v1_only failverify
"$SECRETS_BIN" migrate >/dev/null 2>&1 "$SECRETS_BIN" migrate >/dev/null 2>&1
# corrupt the v2 twin so verify --all fails # corrupt the v2 twin so verify --all fails
printf 'garbage' > "$SECRETS_DIR/failverify/external/"*.properties.age printf 'garbage' > "$SECRETS_DIR/failverify/external/"*.properties.age
@ -224,6 +286,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir untwinned create_project_dir untwinned
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push untwinned >/dev/null 2>&1 "$SECRETS_BIN" push untwinned >/dev/null 2>&1
m_make_v1_only untwinned
# do NOT migrate — leave the v1 blob with no twin # do NOT migrate — leave the v1 blob with no twin
run "$SECRETS_BIN" migrate --finalize --yes run "$SECRETS_BIN" migrate --finalize --yes
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
@ -254,6 +317,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir tagproj create_project_dir tagproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push tagproj >/dev/null 2>&1 "$SECRETS_BIN" push tagproj >/dev/null 2>&1
m_make_v1_only tagproj
"$SECRETS_BIN" migrate >/dev/null 2>&1 "$SECRETS_BIN" migrate >/dev/null 2>&1
"$SECRETS_BIN" migrate --finalize --yes >/dev/null 2>&1 "$SECRETS_BIN" migrate --finalize --yes >/dev/null 2>&1
local tag; tag=$(git -C "$SECRETS_DIR" tag | grep '^pre-v2-migrate-') local tag; tag=$(git -C "$SECRETS_DIR" tag | grep '^pre-v2-migrate-')
@ -269,6 +333,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir confproj create_project_dir confproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push confproj >/dev/null 2>&1 "$SECRETS_BIN" push confproj >/dev/null 2>&1
m_make_v1_only confproj
"$SECRETS_BIN" migrate >/dev/null 2>&1 "$SECRETS_BIN" migrate >/dev/null 2>&1
run bash -c "echo '' | $SECRETS_BIN migrate --finalize" run bash -c "echo '' | $SECRETS_BIN migrate --finalize"
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
@ -352,6 +417,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
create_project_dir needsmig create_project_dir needsmig
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push needsmig >/dev/null 2>&1 # v1 blob, no twin yet "$SECRETS_BIN" push needsmig >/dev/null 2>&1 # v1 blob, no twin yet
m_make_v1_only needsmig
run "$SECRETS_BIN" migrate --status run "$SECRETS_BIN" migrate --status
[ "$status" -ne 0 ] # not finalize-ready [ "$status" -ne 0 ] # not finalize-ready
[[ "$output" == *"needsmig"* ]] || false [[ "$output" == *"needsmig"* ]] || false
@ -371,6 +437,17 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
[[ "$output" == *"Finalize-ready"* ]] || false [[ "$output" == *"Finalize-ready"* ]] || false
} }
@test "migrate --status counts v2-only externals (old clients not served)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir v2onlyext
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push v2onlyext >/dev/null 2>&1 # v2-only (fresh, no v1 twin)
run "$SECRETS_BIN" migrate --status
[ "$status" -eq 0 ] # no v1 blobs -> finalize-ready
[[ "$output" == *"v2-only"* ]] || false # surfaced as v2-only coverage
}
@test "migrate --status on an already-v2 store says nothing to do" { @test "migrate --status on an already-v2 store says nothing to do" {
init_with_remote init_with_remote
create_project_dir v2status create_project_dir v2status

View file

@ -1484,9 +1484,10 @@ gradle_project() {
# ─── init second-machine guard + store .gitignore self-heal ──────────── # ─── init second-machine guard + store .gitignore self-heal ────────────
@test "init with existing key but no repo dies with clone guidance" { @test "init with existing key but no repo dies with join guidance" {
# Second-machine trap: user copies key.txt into ~/.secrets, then runs # Second-machine trap (EGB-671): user copies key.txt into ~/.secrets, then
# `secrets init` instead of cloning their secrets repo. # runs `secrets init` instead of joining their existing vault. The trap now
# points at `secrets join` (the real one-command path), not a manual clone.
mkdir -p "$SECRETS_DIR" mkdir -p "$SECRETS_DIR"
age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
# Guard against a vacuous '' = '' comparison if age-keygen failed # Guard against a vacuous '' = '' comparison if age-keygen failed
@ -1496,7 +1497,7 @@ gradle_project() {
run "$SECRETS_BIN" init run "$SECRETS_BIN" init
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
[[ "$output" == *"git clone"* ]] || false [[ "$output" == *"secrets join"* ]] || false
# Must not leave a half-initialized store behind # Must not leave a half-initialized store behind
[ ! -d "$SECRETS_DIR/.git" ] [ ! -d "$SECRETS_DIR/.git" ]
# Key untouched # Key untouched
@ -1651,7 +1652,7 @@ gradle_project() {
[[ "$output" != *"key.txt"* ]] || false [[ "$output" != *"key.txt"* ]] || false
} }
@test "init guard renders the real clone URL when .secrets-store carries a remote" { @test "init guard renders the real remote URL in join guidance when .secrets-store carries a remote" {
mkdir -p "$HOME/.secrets-work" mkdir -p "$HOME/.secrets-work"
age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
[ -s "$HOME/.secrets-work/key.txt" ] [ -s "$HOME/.secrets-work/key.txt" ]
@ -1660,7 +1661,7 @@ gradle_project() {
run "$SECRETS_BIN" init run "$SECRETS_BIN" init
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false [[ "$output" == *"secrets join --remote git@example.com:me/secrets-work.git"* ]] || false
} }
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ── # ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──
@ -1763,3 +1764,84 @@ file_project() {
[[ "$output" == *"Extracted 1 key"* ]] || false [[ "$output" == *"Extracted 1 key"* ]] || false
[[ "$output" == *"Encrypted file"* ]] || false [[ "$output" == *"Encrypted file"* ]] || false
} }
# ─── EGB-699: `list --json` machine-readable output ──────────────────────
@test "EGB-699: list --json emits valid JSON with project and dotenv entry" {
init_with_remote
create_project_dir jproj
"$SECRETS_BIN" push jproj >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
# entire stdout parses as JSON
echo "$output" | jq -e . >/dev/null
# project is present
echo "$output" | jq -e '.projects[] | select(.name == "jproj")' >/dev/null
# .env shows up as a dotenv entry
echo "$output" | jq -e '.projects[] | select(.name == "jproj")
| .entries[] | select(.type == "dotenv" and .path == ".env")' >/dev/null
}
@test "EGB-699: list --json includes a nested dotenv relpath" {
init_with_remote
create_project_dir nestjson
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "nestjson")
| .entries[] | select(.type == "dotenv" and .path == "packages/web/.env.development")' >/dev/null
}
@test "EGB-699: list --json marks an external properties entry with subtype" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gjson beaconClerkPkTest
"$SECRETS_BIN" push gjson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "gjson")
| .entries[] | select(.type == "external" and .subtype == "properties")' >/dev/null
}
@test "EGB-699: list --json marks an external file entry with subtype" {
init_with_remote
file_src
local dir="$WORK_DIR/fjson"; mkdir -p "$dir"
printf 'file ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir"
"$SECRETS_BIN" push fjson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "fjson")
| .entries[] | select(.type == "external" and .subtype == "file")' >/dev/null
}
@test "EGB-699: list --json on an empty store emits an empty projects array" {
"$SECRETS_BIN" init >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects == []' >/dev/null
}
@test "EGB-699: list --json keeps stdout pure JSON (notices go to stderr)" {
# The non-default-store hint normally prints to stdout in human mode; under
# --json it must not, or it would corrupt the document. Capture stdout only.
init_with_remote
create_project_dir purejson
"$SECRETS_BIN" push purejson >/dev/null 2>&1
local json
json=$("$SECRETS_BIN" list --json 2>/dev/null)
echo "$json" | jq -e . >/dev/null
}
@test "EGB-699: list --json reports the active store path" {
init_with_remote
create_project_dir storejson
"$SECRETS_BIN" push storejson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e --arg s "$SECRETS_DIR" '.store == $s' >/dev/null
}

115
test/upgrade.bats Normal file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env bats
# EGB-716: `secrets upgrade` verb — self-update (git pull --ff-only) + skew re-check.
#
# These tests never touch the real tool checkout. Each test relocates a COPY of
# the script into a throwaway git repo wired to a bare upstream, so $SCRIPT_DIR
# (computed from BASH_SOURCE) resolves to the fake tool repo and the pull/fetch
# operate there.
load test_helper
# Create a fake tool repo at $TOOL (script copy + VERSION), wired to a bare
# upstream at $TOOL_REMOTE, at version $1. cd's into $TOOL (under $HOME so
# resolve_store's walk-up stays bounded and never strays to a real store).
setup_tool_repo() {
TOOL="$TEST_TMPDIR/tool"
TOOL_REMOTE="$TEST_TMPDIR/tool-remote.git"
mkdir -p "$TOOL"
cp "$SECRETS_BIN" "$TOOL/secrets"
echo "$1" > "$TOOL/VERSION"
git -c init.defaultBranch=main init -q "$TOOL"
git -C "$TOOL" add -A
git -C "$TOOL" -c user.email=t@t -c user.name=t commit -qm "v$1"
git -c init.defaultBranch=main init --bare -q "$TOOL_REMOTE"
git -C "$TOOL" remote add origin "$TOOL_REMOTE"
git -C "$TOOL" push -q -u origin HEAD:main
cd "$TOOL"
}
# Publish a newer VERSION to the upstream (as a different clone would).
advance_tool_remote() {
local clone="$TEST_TMPDIR/tool-pub"
rm -rf "$clone"
git clone -q "$TOOL_REMOTE" "$clone"
echo "$1" > "$clone/VERSION"
git -C "$clone" -c user.email=t@t -c user.name=t commit -qam "v$1"
git -C "$clone" push -q origin HEAD:main
rm -rf "$clone"
}
@test "upgrade --check reports an available update without changing VERSION (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
run "$TOOL/secrets" upgrade --check
[ "$status" -eq 0 ]
[[ "$output" == *"Update available"* ]] || false
[[ "$output" == *"0.1.0.0"* ]] || false
# --check must not pull: local VERSION is untouched.
[ "$(cat "$TOOL/VERSION")" = "0.1.0.0" ]
}
@test "upgrade --check is clean when already current (EGB-716)" {
setup_tool_repo 0.2.0.0
run "$TOOL/secrets" upgrade --check
[ "$status" -eq 0 ]
[[ "$output" == *"up to date"* ]] || false
}
@test "upgrade fast-forwards and reports old -> new (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.2.0.0"* ]] || false
[ "$(cat "$TOOL/VERSION")" = "0.2.0.0" ]
}
@test "upgrade is a no-op when already at the latest (EGB-716)" {
setup_tool_repo 0.2.0.0
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"up to date"* ]] || false
[ "$(cat "$TOOL/VERSION")" = "0.2.0.0" ]
}
@test "upgrade refuses when the tool dir is not a git checkout (EGB-716)" {
local d="$HOME/plain-tool"
mkdir -p "$d"
cp "$SECRETS_BIN" "$d/secrets"
echo 0.1.0.0 > "$d/VERSION"
cd "$d"
run "$d/secrets" upgrade
[ "$status" -eq 1 ]
[[ "$output" == *"git checkout"* ]] || false
}
@test "upgrade rejects an unknown flag (EGB-716)" {
setup_tool_repo 0.1.0.0
run "$TOOL/secrets" upgrade --bogus
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown upgrade flag"* ]] || false
}
@test "upgrade re-checks store skew and confirms the client caught up (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.9.0.0
# A store last written by a newer client than our starting version.
git -c init.defaultBranch=main init -q "$SECRETS_DIR"
echo 0.8.0.0 > "$SECRETS_DIR/.secrets-writer-version"
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.9.0.0"* ]] || false
# New client (0.9.0.0) is now ahead of the store's last writer (0.8.0.0).
[[ "$output" == *"at or ahead"* ]] || false
}
@test "upgrade still notes when the store is ahead of the upgraded client (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
git -c init.defaultBranch=main init -q "$SECRETS_DIR"
echo 0.9.0.0 > "$SECRETS_DIR/.secrets-writer-version"
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.2.0.0"* ]] || false
[[ "$output" == *"still ahead"* ]] || false
}

106
test/version.bats Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env bats
# EGB-713 version-skew nudge: writer-version stamp, numeric comparator, skew
# warning, `which` surface. bash 3.2: every standalone [[ ]] ends with || false.
load test_helper
VERSION_FILE() { echo "$(cd "$(dirname "$SECRETS_BIN")" && pwd)/VERSION"; }
# ─── comparator contract ──────────────────────────────────────────────
@test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically (not lexically)" {
run bash -c '
_version_gt() {
local a="$1" b="$2" i ai bi; local -a af bf
IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b"
for i in 0 1 2 3; do
ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0
bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0
if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi
if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi
done; return 1
}
_version_gt 0.10.0.0 0.7.0.0 && echo "10gt7"
_version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10"
_version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt"
_version_gt 1.0.0.0 0.9.9.9 && echo "major_wins"
'
[ "$status" -eq 0 ]
[[ "$output" == *"10gt7"* ]] || false
[[ "$output" == *"7not_gt_10"* ]] || false
[[ "$output" == *"equal_not_gt"* ]] || false
[[ "$output" == *"major_wins"* ]] || false
}
# ─── stamp on write ───────────────────────────────────────────────────
@test "push stamps the store writer-version with the client version" {
init_with_remote
create_project_dir wvstamp
"$SECRETS_BIN" push wvstamp >/dev/null 2>&1
[ -f "$SECRETS_DIR/.secrets-writer-version" ]
run cat "$SECRETS_DIR/.secrets-writer-version"
[ "$output" = "$(cat "$(VERSION_FILE)")" ]
}
@test "writer-version stamp is monotonic (a push never lowers a higher stamp)" {
init_with_remote
create_project_dir wvmono
printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version"
"$SECRETS_BIN" push wvmono >/dev/null 2>&1
run cat "$SECRETS_DIR/.secrets-writer-version"
[ "$output" = "9.9.9.9" ]
}
@test "writer-version stamp is committed, not gitignored" {
init_with_remote
create_project_dir wvcommit
"$SECRETS_BIN" push wvcommit >/dev/null 2>&1
run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version"
[ "$status" -eq 0 ]
}
# ─── skew warning on command ──────────────────────────────────────────
@test "a store written by a newer version warns on a command (non-fatal)" {
init_with_remote
create_project_dir skewwarn
"$SECRETS_BIN" push skewwarn >/dev/null 2>&1
printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"last written by secrets v99.0.0.0"* ]] || false
[[ "$output" == *"Update your secrets tool"* ]] || false
}
@test "a store at the same/older version is silent" {
init_with_remote
create_project_dir noskew
"$SECRETS_BIN" push noskew >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" != *"Update your secrets tool"* ]] || false
}
@test "a store with no writer-version marker is silent (legacy store)" {
init_with_remote
create_project_dir legacynostamp
"$SECRETS_BIN" push legacynostamp >/dev/null 2>&1
rm -f "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" != *"Update your secrets tool"* ]] || false
}
# ─── which surface ────────────────────────────────────────────────────
@test "which prints the store writer-version and a behind note" {
init_with_remote
create_project_dir whichwv
"$SECRETS_BIN" push whichwv >/dev/null 2>&1
printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"written-by: v99.0.0.0"* ]] || false
[[ "$output" == *"behind"* ]] || false
}