Compare commits

..

No commits in common. "main" and "brian/egb-283-secrets-multi-recipient-age-encryption-multiple-keys-per" have entirely different histories.

7 changed files with 35 additions and 1230 deletions

View file

@ -5,88 +5,6 @@ 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.7.7.0] - 2026-09-08
### Fixed
- **pnpm monorepos discovered no workspace secrets (EGB-1232)** — both
workspace call sites resolved patterns from `package.json`'s `workspaces`
key only, which pnpm does not use (it declares `packages:` in
`pnpm-workspace.yaml`). `secrets push -w` refused outright; plain `secrets
push` failed *silently* — `_maybe_workspace_env_files` returned 0 the moment
the key was absent, so the auto-discovery that exists to cover push's
root-only scan was inert on every pnpm repo and printed "Nothing new to
add", indistinguishable from a repo that genuinely had nothing. Workspace
patterns now resolve through one shared source that falls back to
`pnpm-workspace.yaml`.
- **yarn's object `workspaces` form was never expanded (EGB-1232)** — the
filter `.workspaces // .workspaces.packages | .[]` short-circuits on yarn's
truthy object, so `.workspaces.packages` was never evaluated and `.[]`
iterated the object's values, yielding the pattern array itself as a single
token. Only npm's array form worked. Now type-aware, handling npm's array,
yarn's object, and absent/null alike.
### Added
- **`pnpm-workspace.yaml` support** — the `packages:` block sequence is read
without a YAML dependency: block form only, stopping at the next top-level
key so pnpm 10's `onlyBuiltDependencies:`/`catalog:` cannot leak in as glob
patterns, with quote and inline-comment handling and a symlink refusal.
`package.json` wins when it declares workspaces; `pnpm-workspace.yaml` is the
fallback. jq is now required only when `package.json` is the source, so a
pnpm-only repo resolves workspaces jq-free.
- **Workspace patterns are validated before glob expansion** — no absolute
paths, `..` traversal, shell metacharacters, or whitespace reach the
unquoted expansion; pnpm `!` negations are skipped. Same conservative rail
as `.secrets-store` / `.secrets-files`.
- **A monorepo-shaped root that resolves no workspaces now says so** — if a
`pnpm-workspace.yaml` or `packages/` directory is present but no workspace
packages can be read, `push` warns on stderr and points at `secrets add`,
instead of returning in silence. `secrets push -w`'s error now names
`pnpm-workspace.yaml` when that is the file present, rather than blaming a
`package.json` the repo may not use for workspaces.
## [0.7.6.0] - 2026-09-08
### Added
- **`secrets sync` — reconcile a diverged store (EGB-1231)** — the store is a
git repo, and once a clone was both ahead and behind its remote the CLI had
no way out: `push` demanded a fast-forward and pointed at `pull`, which
could not fast-forward either, so the advice looped and recovery meant
hand-running git next to a directory of encrypted blobs. `secrets sync`
fetches, stashes uncommitted blob edits, rebases local commits onto the
remote, restores the stash, and then asks before publishing local commits to
the shared store. `--yes` skips the prompt (scripts/CI); `--dry-run` reports
ahead/behind/dirty and what would happen, changing nothing. Deliberately
non-destructive: no merge, no force-push, no `reset --hard`, no `stash
drop`. A rebase conflict aborts, restores the stash, names the conflicting
files, and leaves the store exactly as found.
- **Store state in `secrets which` (EGB-1231)** — a new `remote:` line reports
the store's `ahead N, behind N, N modified` (or `up to date`) against its
upstream, with a `(run: secrets sync)` hint when there is anything to
reconcile. Offline-safe (reports against the last fetch, never reaches the
network) and silent for a local-only store or one with no upstream.
### Fixed
- **`secrets pull` no longer fails silently when the store can't sync
(EGB-1230)** — the store sync was `git pull >/dev/null 2>&1` under `set -euo
pipefail`, so a store that could not fast-forward killed the script at that
line with git's exit 128 and *nothing* on stdout or stderr. The user saw a
banner, no restored files, and no reason — indistinguishable from a project
with nothing to pull, and easy to lose entirely in a pipeline. The sync is
now guarded, git's output is captured and surfaced as the diagnosis, and the
error names the store path and points at `secrets sync`.
### Changed
- **`secrets pull`'s store sync is now fast-forward only**, matching the push
path. A plain `git pull` could quietly manufacture a merge commit in the
store; divergence is now resolved in exactly one place — `secrets sync`.
- **The push path's dead-end advice** ("Run 'secrets pull' first, then retry
push") now points at `secrets sync` and includes git's own output.
## [0.7.5.0] - 2026-06-24 ## [0.7.5.0] - 2026-06-24
### Added ### Added
@ -475,6 +393,6 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
- 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1F5 adversarial regressions. - 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1F5 adversarial regressions.
[0.1.1.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.1.0 [0.1.1.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.1.0
[0.1.0.1]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.1 [0.1.0.1]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.1
[0.1.0.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.0 [0.1.0.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.0

View file

@ -56,17 +56,16 @@ 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, sync, upgrade. 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. **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. - 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.
- Store sync + divergence (EGB-1230/EGB-1231): the store is a git repo, so a clone can end up ahead of and behind its remote at once. **EGB-1230:** `cmd_pull`'s sync used to be `git pull >/dev/null 2>&1` under `set -euo pipefail` — a store that couldn't fast-forward killed the script there with git's exit 128 and nothing on either stream (a banner, no files, no reason; invisible in a pipeline). It now routes through `_store_sync_pull`, which guards the pull, captures git's output as the diagnosis, and dies naming the store path and `secrets sync`. That sync is **`--ff-only`**, matching the push path — a plain `git pull` could quietly manufacture a merge commit in the store, and divergence is now resolved in exactly one place. **EGB-1231:** `_store_git_state` emits `ahead\tbehind\tdirty` (from `rev-list --left-right --count @{u}...HEAD` plus `status --porcelain`) and `_format_store_state` renders it; `cmd_which` prints a `remote:` line from them — offline-safe (reports against the last fetch), silent with no remote/upstream. `cmd_sync` is the reconcile verb the CLI was missing: fetch → report state → stash (`push -u`) → `rebase @{u}` → restore stash → `ensure_store_protections` (rebased-in history may lack `.gitignore`, and a store missing the `key.txt` line would stage the private key — same reasoning as push) → **confirmation-gated** `git push` of local commits. The gate (`_sync_confirm_push`) reads `/dev/tty` and requires a tty, so it stays CLOSED in scripts/CI rather than publishing to a shared store by default; `--yes` opens it, `--dry-run` reports and returns before any mutation. Non-destructive by construction: no merge, no `--force`, no `reset --hard`, no `stash drop`. A rebase conflict collects the conflicting paths BEFORE `rebase --abort` (the abort clears them), restores the stash, and dies — store byte-identical to how it was found. `_sync_restore_stash` never drops the stash on a failed pop; it tells the operator where their only copy lives. `cmd_sync` does not `_stamp_writer_version`: it replays existing commits rather than authoring content, and the stamp is specified to ride a store-committing `git add -A`. Test suite: `test/sync.bats` (25 tests), including a grep over the `cmd_sync` body asserting the destructive git verbs never appear in it.
- 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 (EGB-1232): `--workspaces` and the plain-push workspace re-scan both resolve patterns through ONE source — `_workspace_patterns()`. It reads `package.json` `.workspaces` via `$WORKSPACES_JQ` (type-aware: handles npm's array AND yarn's object `{packages:[...]}` form) and falls back to `pnpm-workspace.yaml`'s `packages:` block when package.json declares none. **Two defects fixed:** (1) both call sites were package.json-only, so no pnpm monorepo ever resolved a workspace — and `_maybe_workspace_env_files` failed *silently* (`jq -e '.workspaces' ... || return 0`), making auto-discovery inert and `push` print "Nothing new to add", indistinguishable from a repo with nothing new; it bit the same repo twice. (2) the old filter `.workspaces // .workspaces.packages | .[]` short-circuits on yarn's truthy object, iterating the object's values and yielding the pattern ARRAY as a single token. Note a naive reorder does NOT fix it — `.workspaces.packages` errors on an array; hence the `if type == "object"` form. `_pnpm_workspace_packages()` is a deliberate non-parser (block sequence only, stops at the next top-level key so pnpm 10's `onlyBuiltDependencies:`/`catalog:` can't leak in as globs, strips quotes/inline comments, refuses a symlinked file). Patterns are validated by `_valid_workspace_pattern` before they reach the unquoted `for pattern in $patterns` glob expansion (no absolute/`..`/metacharacters/whitespace; pnpm `!` negations skipped) — same posture as `.secrets-store`/`.secrets-files`. `_looks_like_monorepo` + `_workspace_source` turn the old silent return into a warning that names the real file, and `get_workspaces`'s error names `pnpm-workspace.yaml` when that's what's present instead of blaming package.json. jq is required only when package.json is the source. **Scope note:** the workspace re-scan still runs only for projects that already have a `.secrets.json` — push's root-scan-only behavior on a first push is by design (EGB-677 E13), and EGB-1232 is about the fallback that covers it never engaging. Tests: `test/workspaces.bats` (18). - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) - Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`)
- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R` - Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R`
format, `# name` comments) lets one store encrypt every blob to N age keys — format, `# name` comments) lets one store encrypt every blob to N age keys —
@ -98,8 +97,6 @@ test/
manifest.bats # EGB-677 .secrets.json manifest tests (83 tests) manifest.bats # EGB-677 .secrets.json manifest tests (83 tests)
migrate.bats # EGB-703 store-format-v2 migration tests (35 tests) migrate.bats # EGB-703 store-format-v2 migration tests (35 tests)
upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests) upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests)
sync.bats # EGB-1230/1231 store sync + divergence reconcile tests (25 tests)
workspaces.bats # EGB-1232 npm/yarn/pnpm workspace discovery tests (18 tests)
recipients.bats # EGB-283 multi-recipient age encryption tests (34 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
@ -138,43 +135,24 @@ Key design decisions (all driven by /autoplan review):
## Deploy Configuration ## Deploy Configuration
- Platform: NONE (distributed via `git clone` from the private Forgejo at `git.dev.egbt.com`) - Platform: NONE (distributed via `git clone` from Codeberg)
- Production URL: N/A (no live service) - Production URL: N/A (no live service)
- Release model: merge to `main` is the release. Optionally tagged with `v<X.Y.Z.W>`. - Release model: merge to `main` is the release. Optionally tagged with `v<X.Y.Z.W>`.
- Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL. - Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL.
- Staging: none. - Staging: none.
- Rollback: revert the merge commit on `main` (and delete the tag) to roll back. - Rollback: revert the merge commit on `main` (and delete the tag) to roll back.
## Forge operations (self-hosted Forgejo) ## Codeberg operations
The remote is a private Forgejo instance at `https://git.dev.egbt.com` The remote is Codeberg (Forgejo) — `gh`/`glab` do NOT work here. Use `tea`
(migrated off Codeberg 2026-09-08). `gh`/`glab` do NOT work here. Use `tea` (login name: `codeberg`, user `egbt`) for forge operations when a skill's
(login name: `egbt`, user `brian`) for forge operations when a skill's
platform detection comes up "unknown": platform detection comes up "unknown":
**Always pass `--login egbt --repo egbt/secrets` explicitly.** `tea`'s repo - PRs: `tea pr create --base main --title ... --description ...` / `tea pr merge <index>`
autodetection fails here ("remote repository required"), and this machine also - Releases: `tea releases create --tag v<X.Y.Z.W> --title "v<X.Y.Z.W> — <YYYY-MM-DD>" --note ...`
has a leftover `codeberg` login pointing at the *old* forge
(`https://codeberg.org`) that `tea` will silently fall back to in
non-interactive mode — which would target the wrong server. Confirm with
`tea logins list` if a command errors.
- PRs: `tea pr create --login egbt --repo egbt/secrets --base main --head <branch> --title ... --description ...` / `tea pr merge <index> --login egbt --repo egbt/secrets`
- Releases: `tea releases create --login egbt --repo egbt/secrets --tag v<X.Y.Z.W> --title "v<X.Y.Z.W> — <YYYY-MM-DD>" --note ...`
(convention: one release per tag, title `v<X.Y.Z.W> — <date>`) (convention: one release per tag, title `v<X.Y.Z.W> — <date>`)
- Issues/status: `tea issues --login egbt --repo egbt/secrets`, `tea pr list --login egbt --repo egbt/secrets` - Issues/status: `tea issues`, `tea pr list`
- **SSH is on port 2222**, not 22 (port 22 is the host's own sshd). Clone/remote - No CI on this repo: the bats suite run locally is the merge gate.
URLs must be `ssh://git@git.dev.egbt.com:2222/egbt/secrets.git`. A bare
`git@git.dev.egbt.com:egbt/secrets.git` will fail with "Permission denied
(publickey)" because it hits the wrong daemon.
- The host resolves to a Tailscale address — the forge is reachable only on the
VPN. Off-net, push/pull/`tea` all fail to connect; that is expected, not a
credentials problem.
- `FORGEJO_URL` and `FORGEJO_TOKEN` (API token for user `brian`) live in
`~/.zshenv` for direct API calls.
- CI: the instance has an Actions runner available, but no workflow is
configured for this repo yet. The bats suite run locally is still the merge
gate.
## Environment variable ## Environment variable

View file

@ -69,7 +69,7 @@ two commands to finish setup. It never edits your shell config and never runs
sudo — it prints the commands so you stay in control. sudo — it prints the commands so you stay in control.
```bash ```bash
git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets cd ~/dev/secrets
./install.sh ./install.sh
``` ```
@ -82,7 +82,7 @@ this machine with one of the two flows below.
```bash ```bash
# 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a # 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a
# GitLab/Forgejo private repo). It holds only ciphertext — never your key. # Codeberg/GitLab private repo). It holds only ciphertext — never your key.
# Then wire it up and push the store in one command: # Then wire it up and push the store in one command:
secrets init --remote git@github.com:<you>/my-secrets.git secrets init --remote git@github.com:<you>/my-secrets.git
@ -108,7 +108,7 @@ On a second machine, a desktop, or a teammate's laptop:
```bash ```bash
# 1. Clone the tool and run the bootstrap (as in Setup above) # 1. Clone the tool and run the bootstrap (as in Setup above)
git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets && ./install.sh # add the printed PATH line to your shell config 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): # 2. Get key.txt onto this machine (the one manual, out-of-band step):
@ -193,9 +193,6 @@ secrets clear
| `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 sync` | Reconcile a store that has diverged from its remote: stash local blob edits, rebase onto the remote, restore the stash, then offer to publish your local commits. Never merges, force-pushes, or hard-resets |
| `secrets sync --dry-run` | Report the store's ahead/behind/dirty state and what a reconcile would do; changes nothing |
| `secrets sync --yes` | Reconcile and publish local commits without the confirmation prompt (for scripts) |
| `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` | 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 | | `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing |
@ -214,32 +211,6 @@ Store-format v2 is **additive** — an upgraded client reads either blob suffix
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. 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.
### When the store diverges
The store is a git repo, so two machines pushing at once can leave your clone
both ahead and behind its remote. `secrets push` needs a fast-forward and
`secrets pull` won't silently merge, so both stop and tell you to run:
```bash
secrets sync
```
`sync` fetches, stashes any uncommitted blob edits, rebases your local commits
onto the remote, restores the stash, and then *asks* before publishing your
commits to the shared store (`--yes` skips the prompt; `--dry-run` just
reports). If the rebase conflicts, it aborts, restores your stash, names the
conflicting files, and leaves the store exactly as it found it — nothing in
the path force-pushes, hard-resets, or drops a stash.
`secrets which` now reports the same state up front, so you can see it coming:
```
store: /Users/you/.secrets
source: default
format: v2
remote: ahead 1, behind 11, 3 modified (run: secrets sync)
```
### Automatic project detection ### Automatic project detection
When you run `secrets push` or `secrets pull` without specifying a project name, the tool figures out which project you're in by: When you run `secrets push` or `secrets pull` without specifying a project name, the tool figures out which project you're in by:
@ -425,10 +396,10 @@ git commit -am "switch to personal secrets"
### Monorepo support ### Monorepo support
For projects with multiple packages, add the `-w` flag to operate on all workspaces at once: For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once:
```bash ```bash
cd ~/myapp # npm/yarn workspaces, or a pnpm-workspace.yaml cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
secrets push -w # encrypts secrets from root + each workspace secrets push -w # encrypts secrets from root + each workspace
secrets pull -w # decrypts into root + each workspace directory secrets pull -w # decrypts into root + each workspace directory
secrets clear -w # clears secrets from root + each workspace secrets clear -w # clears secrets from root + each workspace
@ -445,22 +416,7 @@ Inside `~/.secrets/`, workspace secrets are organized by path:
apps/api/.env.age # api workspace apps/api/.env.age # api workspace
``` ```
**Where workspaces are declared.** All three package managers are supported: Requires `jq` (`brew install jq`).
| Manager | Declaration |
|---|---|
| npm | `package.json``"workspaces": ["apps/*"]` |
| yarn | `package.json``"workspaces": {"packages": ["apps/*"]}` |
| pnpm | `pnpm-workspace.yaml``packages:` block |
`package.json` wins when it declares any workspaces; `pnpm-workspace.yaml` is
the fallback. Reading `package.json` requires `jq` (`brew install jq`); a
pnpm-only repo needs no jq for workspace discovery.
If a root *looks* like a monorepo (a `pnpm-workspace.yaml` or a `packages/`
directory) but no workspace packages can be read from it, `secrets push` says
so on stderr rather than silently discovering nothing — that silence
previously made a pnpm repo indistinguishable from one with nothing to sync.
### External files (Gradle properties) ### External files (Gradle properties)

View file

@ -1 +1 @@
0.7.7.0 0.7.5.0

404
secrets
View file

@ -986,14 +986,6 @@ pull_external_files() {
SECRETS_JSON_NAME=".secrets.json" SECRETS_JSON_NAME=".secrets.json"
MANIFEST_VERSION=2 MANIFEST_VERSION=2
# EGB-1232: package.json workspaces, type-aware. The old filter was
# `.workspaces // .workspaces.packages` — `//` short-circuits on yarn's
# truthy OBJECT form, so `.workspaces.packages` was never evaluated and
# `.[]` iterated the object's values, yielding the pattern array itself
# rather than the patterns. Only npm's array form ever worked. Reordering
# alone is not the fix either: `.workspaces.packages` errors on an array.
WORKSPACES_JQ='(.workspaces | if type == "object" then .packages else . end) // empty | .[]?'
# Validate a project-relative dotenv path from the manifest (or `secrets # Validate a project-relative dotenv path from the manifest (or `secrets
# add`). The path is attacker-influenced in team repos (committed file), # add`). The path is attacker-influenced in team repos (committed file),
# so this is a security rail, not just hygiene. Prints an error and # so this is a security rail, not just hygiene. Prints an error and
@ -1241,22 +1233,9 @@ _legacy_absorb_json() {
# after the one-time --workspaces generator run (EGB-677 E13). # after the one-time --workspaces generator run (EGB-677 E13).
_maybe_workspace_env_files() { _maybe_workspace_env_files() {
local root="$1" local root="$1"
# EGB-1232: this used to require package.json + a `.workspaces` key and [ -f "$root/package.json" ] || return 0
# `return 0` otherwise — so on every pnpm repo the guard fired immediately, command -v jq >/dev/null 2>&1 || return 0
# auto-discovery was inert, and `push` printed "Nothing new to add", jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 || return 0
# indistinguishable from a repo that genuinely had nothing. The silence was
# the damaging half of the bug: it cost two sessions on the same repo.
local patterns
patterns=$(_workspace_patterns "$root")
if [ -z "$patterns" ]; then
if _looks_like_monorepo "$root"; then
local src; src=$(_workspace_source "$root") || src="$root"
echo "WARNING: $(basename "$src") is present but declares no readable workspace packages —" >&2
echo " workspace env files were NOT discovered. Declare them explicitly with:" >&2
echo " secrets add <path/to/.env>" >&2
fi
return 0
fi
local ws f local ws f
while IFS= read -r ws; do while IFS= read -r ws; do
[ -n "$ws" ] || continue [ -n "$ws" ] || continue
@ -1270,142 +1249,17 @@ _maybe_workspace_env_files() {
# ─── End manifest ────────────────────────────────────────────────────── # ─── End manifest ──────────────────────────────────────────────────────
# ─── Workspace pattern sources (EGB-1232) ────────────────────────────── # Read package.json workspaces and expand globs to actual directories.
#
# One source of pattern truth for every caller. Both call sites used to read
# package.json's `workspaces` key ONLY, so no pnpm monorepo ever resolved a
# workspace — pnpm declares them in pnpm-workspace.yaml instead.
# A committed project file whose contents get word-split and glob-expanded
# below, so it gets the same conservative rail as .secrets-store/.secrets-files:
# no absolute paths, no traversal, no shell metacharacters, no whitespace.
# pnpm's `!`-prefixed negations are not directory patterns and are skipped.
_valid_workspace_pattern() {
case "$1" in
"") return 1 ;;
/*) return 1 ;;
*..*) return 1 ;;
'!'*) return 1 ;;
*[!A-Za-z0-9/._*@-]*) return 1 ;;
esac
return 0
}
# Read the `packages:` block sequence out of a pnpm-workspace.yaml.
# Deliberately not a YAML parser — one top-level key, block sequence form,
# which is what pnpm writes. Stops at the next top-level key so pnpm 10's
# sibling keys (onlyBuiltDependencies:, catalog:, …) can never leak in as
# glob patterns. Symlinked file refused, matching the other manifest readers.
_pnpm_workspace_packages() {
local f="$1"
[ -f "$f" ] || return 0
if [ -L "$f" ]; then
echo "WARNING: $f is a symlink — skipping it." >&2
return 0
fi
local line lead trimmed item in_block=0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
lead="${line%%[![:space:]]*}"
trimmed="${line#"$lead"}"
[ -n "$trimmed" ] || continue
case "$trimmed" in '#'*) continue ;; esac
if [ "$in_block" -eq 0 ]; then
# Only a top-level `packages:` opens the block.
if [ -z "$lead" ]; then
case "$trimmed" in packages:*) in_block=1 ;; esac
fi
continue
fi
# Inside the block: a line at column 0 that is not a list item is the
# next top-level key, which ends it.
if [ -z "$lead" ]; then
case "$trimmed" in -*) ;; *) break ;; esac
fi
case "$trimmed" in -*) ;; *) continue ;; esac
item="${trimmed#-}"
lead="${item%%[![:space:]]*}"
item="${item#"$lead"}"
# Quoted forms take everything up to the closing quote (so a trailing
# `# comment` never lands in the pattern); bare forms stop at a comment.
case "$item" in
\"*) item="${item#\"}"; item="${item%%\"*}" ;;
\'*) item="${item#\'}"; item="${item%%\'*}" ;;
*) item="${item%%#*}"; item="${item%"${item##*[![:space:]]}"}" ;;
esac
[ -n "$item" ] && printf '%s\n' "$item"
done < "$f"
}
# The file that supplies (or should have supplied) this root's workspace
# patterns — so errors name the file that actually exists rather than
# blaming package.json on a repo that doesn't use it.
_workspace_source() {
local root="$1"
if [ -f "$root/package.json" ] && command -v jq >/dev/null 2>&1 \
&& [ -n "$(jq -r "$WORKSPACES_JQ" "$root/package.json" 2>/dev/null)" ]; then
printf '%s' "$root/package.json"; return 0
fi
[ -f "$root/pnpm-workspace.yaml" ] && { printf '%s' "$root/pnpm-workspace.yaml"; return 0; }
[ -f "$root/package.json" ] && { printf '%s' "$root/package.json"; return 0; }
return 1
}
# Does this root look like a monorepo even though no patterns resolved?
# Used to turn the old silent `return 0` into something a user can act on.
_looks_like_monorepo() {
local root="$1"
[ -f "$root/pnpm-workspace.yaml" ] && return 0
[ -d "$root/packages" ] && return 0
if [ -f "$root/package.json" ] && command -v jq >/dev/null 2>&1; then
jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 && return 0
fi
return 1
}
# Validated workspace glob patterns for a root, one per line.
# package.json wins when it declares any; pnpm-workspace.yaml is the fallback.
_workspace_patterns() {
local root="$1" patterns="" p out=""
if [ -f "$root/package.json" ] && command -v jq >/dev/null 2>&1; then
patterns=$(jq -r "$WORKSPACES_JQ" "$root/package.json" 2>/dev/null || true)
fi
if [ -z "$patterns" ]; then
patterns=$(_pnpm_workspace_packages "$root/pnpm-workspace.yaml")
fi
while IFS= read -r p; do
[ -n "$p" ] || continue
if _valid_workspace_pattern "$p"; then
out="$out$p"$'\n'
else
echo "WARNING: ignoring unsafe workspace pattern: $p" >&2
fi
done <<< "$patterns"
printf '%s' "$out"
}
# Expand a root's workspace patterns to actual directories.
# Prints one workspace path per line (relative to the monorepo root). # Prints one workspace path per line (relative to the monorepo root).
get_workspaces() { get_workspaces() {
local root="$1" local root="$1"
local pkg="$root/package.json"
[ -f "$pkg" ] || die "No package.json found in $root"
check_cmd jq
local patterns local patterns
patterns=$(_workspace_patterns "$root") patterns=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null)
if [ -z "$patterns" ]; then [ -n "$patterns" ] || die "No workspaces field in $pkg"
# A package.json we can't read for want of jq is a tooling problem, not a
# missing declaration — say which it is.
if [ -f "$root/package.json" ] && [ ! -f "$root/pnpm-workspace.yaml" ] \
&& ! command -v jq >/dev/null 2>&1; then
check_cmd jq
fi
local src
src=$(_workspace_source "$root") \
|| die "No package.json or pnpm-workspace.yaml found in $root"
die "No workspaces declared in $src"
fi
# Expand each glob pattern relative to root # Expand each glob pattern relative to root
local old_dir="$PWD" local old_dir="$PWD"
@ -1485,65 +1339,6 @@ ensure_store_protections() {
fi fi
} }
# ─── Store git state (EGB-1230 / EGB-1231) ─────────────────────────────
# True when the store has an 'origin' remote. Everything below is a no-op
# without one — a local-only store can never be out of sync with anything.
_store_has_remote() {
git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1
}
# Emit "<ahead>\t<behind>\t<dirty>" for the store against its upstream.
# Reads only what git already knows — the caller decides whether to fetch
# first, so `which` stays cheap and offline-safe while `sync` sees fresh
# counts. Returns 1 (emitting nothing) when there is no upstream to compare
# against, which is the normal state for a local-only store.
_store_git_state() {
_store_has_remote || return 1
git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1 || return 1
local counts ahead behind dirty
# --left-right --count prints "<behind>\t<ahead>" for @{u}...HEAD.
counts=$(git -C "$SECRETS_DIR" rev-list --left-right --count '@{u}...HEAD' 2>/dev/null) || return 1
behind=$(printf '%s' "$counts" | awk '{print $1}')
ahead=$(printf '%s' "$counts" | awk '{print $2}')
dirty=$(git -C "$SECRETS_DIR" status --porcelain 2>/dev/null | grep -c . || true)
# `grep -c` on empty input exits 1 under `set -e`; normalize to a number.
[ -n "$dirty" ] || dirty=0
printf '%s\t%s\t%s\n' "${ahead:-0}" "${behind:-0}" "$dirty"
}
# Render a state triple as one human line: "ahead 1, behind 11, 3 modified",
# or "up to date" when there is genuinely nothing to reconcile.
_format_store_state() {
local ahead="$1" behind="$2" dirty="$3" parts=""
[ "$ahead" -gt 0 ] 2>/dev/null && parts="ahead $ahead"
[ "$behind" -gt 0 ] 2>/dev/null && parts="${parts:+$parts, }behind $behind"
[ "$dirty" -gt 0 ] 2>/dev/null && parts="${parts:+$parts, }$dirty modified"
printf '%s' "${parts:-up to date}"
}
# Sync the store from its remote before reading or writing blobs.
#
# EGB-1230: this used to be `git pull >/dev/null 2>&1` under `set -euo
# pipefail`, so a store that could not fast-forward killed the script at
# that line with git's exit 128 and NOTHING on either stream — the user saw
# a banner, no files, and no reason. git's output is the diagnosis; capture
# it and put it in the error.
#
# Fast-forward only, matching the push path: a plain `git pull` can quietly
# manufacture a merge commit in the store. Divergence gets resolved in
# exactly one place — `secrets sync`.
_store_sync_pull() {
_store_has_remote || return 0
local out
if ! out=$(git -C "$SECRETS_DIR" pull --ff-only 2>&1); then
die "Store sync failed ($SECRETS_DIR):
$(printf '%s\n' "$out" | sed 's/^/ /')
The store has diverged from its remote or has local changes.
Run: secrets sync"
fi
}
# ─── Subcommands ─────────────────────────────────────────────────────── # ─── Subcommands ───────────────────────────────────────────────────────
cmd_init() { cmd_init() {
@ -1800,14 +1595,8 @@ commit_and_push_secrets() {
local message="$1" local message="$1"
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
# EGB-1231: this used to point at `secrets pull`, which cannot resolve a if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then
# diverged store either — the advice looped. `secrets sync` is the one die "Fast-forward pull failed. Run 'secrets pull' first, then retry push."
# command that reconciles.
local ff_out
if ! ff_out=$(git -C "$SECRETS_DIR" pull --ff-only 2>&1); then
die "Store sync failed ($SECRETS_DIR):
$(printf '%s\n' "$ff_out" | sed 's/^/ /')
Run 'secrets sync' to reconcile, then retry push."
fi fi
fi fi
@ -2076,8 +1865,10 @@ cmd_pull() {
info "Pulling secrets for project: $project" info "Pulling secrets for project: $project"
echo_store_if_non_default echo_store_if_non_default
# Pull latest. Guarded and unsilenced — see _store_sync_pull (EGB-1230). # Pull latest
_store_sync_pull if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
fi
# ── Manifest-driven pull (EGB-677 stage 1) ── # ── Manifest-driven pull (EGB-677 stage 1) ──
# With a .secrets.json present, the manifest decides what restores and # With a .secrets.json present, the manifest decides what restores and
@ -2830,19 +2621,6 @@ 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)"
# EGB-1231: the one thing `which` never reported was the thing that
# actually blocks push and pull — whether the clone is ahead/behind/dirty
# relative to its remote. Offline-safe: reports against the last fetch,
# never reaches the network, and stays silent for a local-only store.
local _st _a _b _d
if _st=$(_store_git_state); then
IFS=$'\t' read -r _a _b _d <<< "$_st"
if [ "$_a" -gt 0 ] || [ "$_b" -gt 0 ] || [ "$_d" -gt 0 ]; then
echo "remote: $(_format_store_state "$_a" "$_b" "$_d") (run: secrets sync)"
else
echo "remote: $(_format_store_state "$_a" "$_b" "$_d")"
fi
fi
local _wv; _wv=$(_store_writer_version) local _wv; _wv=$(_store_writer_version)
if [ -n "$_wv" ]; then if [ -n "$_wv" ]; then
local _cv; _cv=$(_client_version) local _cv; _cv=$(_client_version)
@ -2925,154 +2703,6 @@ cmd_which() {
# After a real update it best-effort re-checks the store's writer-version skew # 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 # 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). # nudge is now cleared (the new code itself takes effect on the next command).
# Put a stashed working tree back. Never drops the stash on failure — the
# entry is the only copy of the operator's uncommitted blob edits.
_sync_restore_stash() {
[ "$1" = true ] || return 0
local out
if out=$(git -C "$SECRETS_DIR" stash pop 2>&1); then
info "Restored your local store changes."
return 0
fi
echo "WARNING: could not restore your stashed store changes:" >&2
printf '%s\n' "$out" | sed 's/^/ /' >&2
echo "They are SAFE and still stashed. Inspect with:" >&2
echo " git -C \"$SECRETS_DIR\" stash list" >&2
echo " git -C \"$SECRETS_DIR\" stash show -p" >&2
return 1
}
# The push gate (EGB-1231): publishing local commits to a store other people
# pull from is an outward-facing act, so it needs an explicit yes. No tty
# means no confirmation — the gate stays closed rather than opening by
# default in scripts and CI.
_sync_confirm_push() {
[ -t 0 ] && [ -e /dev/tty ] || return 1
printf "Publish %s local commit(s) to the shared store? [y/N] " "$1" > /dev/tty 2>/dev/null || return 1
local ans=""
read -r ans < /dev/tty 2>/dev/null || return 1
case "$ans" in [Yy]*) return 0 ;; *) return 1 ;; esac
}
# EGB-1231: reconcile a store that has diverged from its remote.
#
# This is the command that was missing. Before it, `push` demanded a
# fast-forward and told you to run `pull`, and `pull` could not fast-forward
# either — the advice looped and the only way out was hand-running git next
# to a directory of encrypted blobs, which is where someone reaches for
# `reset --hard` and destroys the local work.
#
# Deliberately non-destructive: stash (never drop), rebase (never merge,
# never force), abort-and-restore on any conflict. Every failure path leaves
# the store exactly as it was found.
cmd_sync() {
check_cmd git
resolve_store
check_initialized
local dry_run=false assume_yes=false
while [ $# -gt 0 ]; do
case "$1" in
--dry-run|-n) dry_run=true; shift ;;
--yes|-y) assume_yes=true; shift ;;
-*) die "Unknown sync flag: $1. Usage: secrets sync [--dry-run] [--yes]" ;;
*) die "secrets sync takes no arguments (got: $1)" ;;
esac
done
if ! _store_has_remote; then
die "Store has no remote configured — nothing to reconcile ($SECRETS_DIR).
Wire one with: git -C \"$SECRETS_DIR\" remote add origin <url>"
fi
info "Reconciling store: $SECRETS_DIR"
local out
if ! out=$(git -C "$SECRETS_DIR" fetch origin 2>&1); then
die "Fetch failed ($SECRETS_DIR):
$(printf '%s\n' "$out" | sed 's/^/ /')"
fi
local st ahead behind dirty
if ! st=$(_store_git_state); then
die "The store's branch has no upstream to reconcile against ($SECRETS_DIR).
Set one with: git -C \"$SECRETS_DIR\" branch --set-upstream-to origin/<branch>"
fi
IFS=$'\t' read -r ahead behind dirty <<< "$st"
echo " state: $(_format_store_state "$ahead" "$behind" "$dirty")"
if [ "$dry_run" = true ]; then
if [ "$behind" -gt 0 ]; then
echo " would: rebase $ahead local commit(s) onto origin ($behind to integrate)"
elif [ "$ahead" -gt 0 ]; then
echo " would: publish $ahead local commit(s)"
fi
[ "$dirty" -gt 0 ] && echo " would: stash and restore $dirty uncommitted change(s)"
info "Dry run — nothing changed."
return 0
fi
if [ "$ahead" -eq 0 ] && [ "$behind" -eq 0 ]; then
info "Nothing to reconcile."
return 0
fi
# Stash before touching history: a rebase refuses to start on a dirty tree,
# and those uncommitted .age edits may be the only copy of a secret.
local stashed=false
if [ "$dirty" -gt 0 ]; then
if ! out=$(git -C "$SECRETS_DIR" stash push -u -m "secrets sync" 2>&1); then
die "Could not stash local store changes ($SECRETS_DIR):
$(printf '%s\n' "$out" | sed 's/^/ /')"
fi
stashed=true
info "Stashed $dirty uncommitted change(s)."
fi
if [ "$behind" -gt 0 ]; then
if ! out=$(git -C "$SECRETS_DIR" rebase '@{u}' 2>&1); then
# Collect the conflicting paths BEFORE aborting — the abort clears them.
local conflicts
conflicts=$(git -C "$SECRETS_DIR" diff --name-only --diff-filter=U 2>/dev/null || true)
git -C "$SECRETS_DIR" rebase --abort >/dev/null 2>&1 || true
_sync_restore_stash "$stashed" || true
die "Store reconcile hit a conflict — the store is unchanged.
Conflicting files:
$(printf '%s\n' "${conflicts:-(see git output below)}" | sed 's/^/ /')
$(printf '%s\n' "$out" | sed 's/^/ /')
Resolve by hand in $SECRETS_DIR, or ask the other machine to push again."
fi
info "Rebased onto origin."
fi
_sync_restore_stash "$stashed" || true
# Same reasoning as the push path: rebased-in history may lack .gitignore,
# and a store missing the key.txt line would stage the private key.
ensure_store_protections
# Re-read state — the rebase changed it.
st=$(_store_git_state) || st=$'0\t0\t0'
IFS=$'\t' read -r ahead behind dirty <<< "$st"
if [ "$ahead" -gt 0 ]; then
if [ "$assume_yes" != true ] && ! _sync_confirm_push "$ahead"; then
info "Reconciled locally. $ahead local commit(s) NOT published."
echo " Publish them with: secrets sync --yes"
return 0
fi
if ! out=$(git -C "$SECRETS_DIR" push 2>&1); then
die "Push failed ($SECRETS_DIR):
$(printf '%s\n' "$out" | sed 's/^/ /')"
fi
info "Published $ahead local commit(s)."
fi
st=$(_store_git_state) || st=$'0\t0\t0'
IFS=$'\t' read -r ahead behind dirty <<< "$st"
info "Store reconciled: $(_format_store_state "$ahead" "$behind" "$dirty")"
}
cmd_upgrade() { cmd_upgrade() {
local check_only=0 local check_only=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@ -3613,9 +3243,6 @@ Usage:
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
secrets reencrypt Re-encrypt every blob to the current recipients secrets reencrypt Re-encrypt every blob to the current recipients
secrets sync Reconcile a diverged store with its remote
secrets sync --dry-run Report ahead/behind/dirty; change nothing
secrets sync --yes Reconcile and publish local commits without prompting
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`
@ -3792,7 +3419,6 @@ 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 ;;
sync) shift; cmd_sync "$@" ;;
upgrade) shift; cmd_upgrade "$@" ;; 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." ;;

View file

@ -1,373 +0,0 @@
#!/usr/bin/env bats
# EGB-1230 / EGB-1231: store sync — loud failures on `pull`, and a `secrets
# sync` verb that reconciles a diverged store instead of dead-ending.
#
# bash 3.2 gotcha (see CLAUDE.md): every standalone [[ ]] assertion MUST end
# with `|| false`, or a failing assertion does not fail the test.
load test_helper
# ─── fixtures ──────────────────────────────────────────────────────────
#
# A "peer" is a second clone of the same bare remote. Committing + pushing
# from the peer is how we put the store under test *behind* its remote
# without touching the store itself.
peer_commit_and_push() {
local name="${1:-peer-file}" content="${2:-peer}"
local peer="$TEST_TMPDIR/peer"
if [ ! -d "$peer" ]; then
git clone "$REMOTE_DIR" "$peer" >/dev/null 2>&1
fi
( cd "$peer" && git pull >/dev/null 2>&1 || true )
echo "$content" > "$peer/$name"
( cd "$peer" && git add -A && git commit -m "peer: $name" >/dev/null && git push >/dev/null 2>&1 )
}
# Give the store a local-only commit (store becomes "ahead").
store_local_commit() {
local name="${1:-local-file}"
echo "local" > "$SECRETS_DIR/$name"
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "local: $name" >/dev/null
}
# Leave an uncommitted modification in the store working tree ("dirty").
store_dirty() {
echo "scratch" > "$SECRETS_DIR/${1:-dirty-file}"
git -C "$SECRETS_DIR" add -A >/dev/null 2>&1 || true
}
store_head() { git -C "$SECRETS_DIR" rev-parse HEAD; }
# init_with_remote (shared helper) commits --allow-empty, which leaves the
# files `secrets init` wrote — .gitignore, .secrets-format, recipients.txt —
# untracked. That is a genuinely dirty store, so tests that assert on the
# "nothing to reconcile" path must land them first.
init_clean_store() {
init_with_remote
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "store: initial files" >/dev/null 2>&1 || true
git -C "$SECRETS_DIR" push >/dev/null 2>&1
}
# ─── EGB-1230: pull must never fail silently ───────────────────────────
@test "EGB-1230: pull surfaces git's diagnosis when the store can't fast-forward" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt # now diverged: ahead 1, behind 1
create_project_dir divproj
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
# The failure is named, not silent — this is the whole bug.
[[ "$output" == *"Store sync failed"* ]] || false
[[ "$output" == *"$SECRETS_DIR"* ]] || false
}
@test "EGB-1230: pull's sync failure points at secrets sync" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
create_project_dir divproj
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"secrets sync"* ]] || false
}
@test "EGB-1230: pull writes the diagnosis to stderr, not just stdout" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
create_project_dir divproj
run bash -c "'$SECRETS_BIN' pull 2>&1 1>/dev/null"
[[ "$output" == *"Store sync failed"* ]] || false
}
@test "EGB-1230: a clean store still pulls normally" {
init_with_remote
create_project_dir cleanproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -eq 0 ]
[ -f .env ]
}
@test "EGB-1230: pull's sync is fast-forward only (no silent merge commit)" {
init_with_remote
create_project_dir ffproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
local before; before=$(store_head)
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
# A merge commit would have moved HEAD. Nothing was integrated.
[ "$(store_head)" = "$before" ]
}
# ─── EGB-1231: which reports store state ───────────────────────────────
@test "EGB-1231: which reports ahead/behind/dirty for a diverged store" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1
create_project_dir whichproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"remote:"* ]] || false
[[ "$output" == *"ahead 1"* ]] || false
[[ "$output" == *"behind 1"* ]] || false
}
@test "EGB-1231: which reports an in-sync store as up to date" {
init_clean_store
create_project_dir syncedproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"remote:"* ]] || false
[[ "$output" == *"up to date"* ]] || false
}
@test "EGB-1231: which stays quiet about the remote when none is configured" {
run "$SECRETS_BIN" init
create_project_dir noremote
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" != *"remote:"* ]] || false
}
@test "EGB-1231: which reports a dirty store working tree" {
init_with_remote
store_dirty scratch.age
create_project_dir dirtyproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"modified"* ]] || false
}
# ─── EGB-1231: secrets sync reconciles ─────────────────────────────────
@test "EGB-1231: sync rebases a diverged store onto the remote" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
# Both sides' work survives the reconcile.
[ -f "$SECRETS_DIR/remote-only.txt" ]
[ -f "$SECRETS_DIR/local-only.txt" ]
}
@test "EGB-1231: sync leaves the store able to pull again" {
init_with_remote
create_project_dir recovered
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -eq 0 ]
[ -f .env ]
}
@test "EGB-1231: sync fast-forwards a store that is only behind" {
init_with_remote
peer_commit_and_push remote-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/remote-only.txt" ]
}
@test "EGB-1231: sync stashes and restores a dirty working tree" {
init_with_remote
peer_commit_and_push remote-only.txt
echo "uncommitted work" > "$SECRETS_DIR/scratch.age"
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/remote-only.txt" ]
# The local uncommitted blob edit is NOT lost.
[ -f "$SECRETS_DIR/scratch.age" ]
[ "$(cat "$SECRETS_DIR/scratch.age")" = "uncommitted work" ]
}
@test "EGB-1231: sync on an already-clean store reports no work and changes nothing" {
init_clean_store
local before; before=$(store_head)
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
[ "$(store_head)" = "$before" ]
[[ "$output" == *"up to date"* ]] || false
}
@test "EGB-1231: sync dies directed when the store has no remote" {
run "$SECRETS_BIN" init
run "$SECRETS_BIN" sync --yes
[ "$status" -ne 0 ]
[[ "$output" == *"no remote"* ]] || false
}
# ─── EGB-1231: --dry-run changes nothing ───────────────────────────────
@test "EGB-1231: sync --dry-run reports state without mutating the store" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
local before; before=$(store_head)
run "$SECRETS_BIN" sync --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"ahead 1"* ]] || false
[[ "$output" == *"behind 1"* ]] || false
[ "$(store_head)" = "$before" ]
[ ! -f "$SECRETS_DIR/remote-only.txt" ]
}
@test "EGB-1231: sync --dry-run does not push local commits" {
init_with_remote
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --dry-run
[ "$status" -eq 0 ]
# The remote never received the local commit.
run git -C "$REMOTE_DIR" log --oneline
[[ "$output" != *"local: local-only.txt"* ]] || false
}
# ─── EGB-1231: the push gate ───────────────────────────────────────────
@test "EGB-1231: sync --yes pushes reconciled local commits to the remote" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
run git -C "$REMOTE_DIR" log --oneline
[[ "$output" == *"local: local-only.txt"* ]] || false
}
@test "EGB-1231: sync without confirmation reconciles locally but does not push" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
# No tty and no --yes: the push gate must not open on its own.
run bash -c "'$SECRETS_BIN' sync < /dev/null"
[ "$status" -eq 0 ]
# Local reconcile happened...
[ -f "$SECRETS_DIR/remote-only.txt" ]
# ...but nothing was published to the shared store.
run git -C "$REMOTE_DIR" log --oneline
[[ "$output" != *"local: local-only.txt"* ]] || false
}
@test "EGB-1231: sync says how to publish when the push gate stays closed" {
init_with_remote
store_local_commit local-only.txt
run bash -c "'$SECRETS_BIN' sync < /dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *"--yes"* ]] || false
}
# ─── EGB-1231: conflicts restore the store as found ────────────────────
@test "EGB-1231: a rebase conflict leaves the store exactly as it was" {
init_with_remote
# Both sides edit the same path — a guaranteed rebase conflict.
peer_commit_and_push contested.txt "from-remote"
git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1
echo "from-local" > "$SECRETS_DIR/contested.txt"
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "local: contested" >/dev/null
local before; before=$(store_head)
run "$SECRETS_BIN" sync --yes
[ "$status" -ne 0 ]
[ "$(store_head)" = "$before" ]
# No half-finished rebase left behind for the user to trip over.
[ ! -d "$SECRETS_DIR/.git/rebase-merge" ]
[ ! -d "$SECRETS_DIR/.git/rebase-apply" ]
[ "$(cat "$SECRETS_DIR/contested.txt")" = "from-local" ]
}
@test "EGB-1231: a rebase conflict names the conflicting files" {
init_with_remote
peer_commit_and_push contested.txt "from-remote"
git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1
echo "from-local" > "$SECRETS_DIR/contested.txt"
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "local: contested" >/dev/null
run "$SECRETS_BIN" sync --yes
[ "$status" -ne 0 ]
[[ "$output" == *"contested.txt"* ]] || false
}
@test "EGB-1231: sync never force-pushes or hard-resets" {
# Guard rail on the implementation itself: this store holds the only
# copy of encrypted secrets, so the destructive git verbs must not
# appear anywhere in the sync path.
run bash -c "sed -n '/^cmd_sync()/,/^}/p' '$SECRETS_BIN'"
[[ "$output" != *"--force"* ]] || false
[[ "$output" != *"reset --hard"* ]] || false
[[ "$output" != *"stash drop"* ]] || false
}
# ─── EGB-1231: the push path's advice is no longer a dead end ──────────
@test "EGB-1231: push's fast-forward failure points at secrets sync" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
create_project_dir pushproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"secrets sync"* ]] || false
}
# ─── help ──────────────────────────────────────────────────────────────
@test "EGB-1231: sync is documented in help" {
run "$SECRETS_BIN" help
[ "$status" -eq 0 ]
[[ "$output" == *"secrets sync"* ]] || false
}

View file

@ -1,300 +0,0 @@
#!/usr/bin/env bats
# EGB-1232: workspace discovery must not be npm-only.
#
# Two defects, both in the pattern-source layer:
# 1. pnpm declares workspaces in pnpm-workspace.yaml, not package.json's
# `workspaces` key — so both call sites came up empty on every pnpm
# monorepo (silently, in the plain-push path).
# 2. the jq filter `.workspaces // .workspaces.packages` short-circuits on
# yarn's truthy object form, iterating the object's values and yielding
# the pattern array itself instead of the patterns.
#
# bash 3.2 gotcha (see CLAUDE.md): every standalone [[ ]] assertion MUST end
# with `|| false`, or a failing assertion does not fail the test.
load test_helper
# ─── fixtures ──────────────────────────────────────────────────────────
# A monorepo root with two workspace packages, each holding a .env.
# The workspace *declaration* is left to the caller — that is what varies.
# NB: the workspace re-scan (_maybe_workspace_env_files) runs only for
# projects that already have a .secrets.json — push's root-scan-only
# behaviour on a first push is by design (EGB-677 E13), and EGB-1232 is
# about the fallback that covers it never engaging. So every fixture
# carries a manifest, matching the reported repro.
make_monorepo() {
local dir="$WORK_DIR/${1:-mono}"
mkdir -p "$dir/apps/web" "$dir/apps/admin"
echo "VITE_CLERK_PUBLISHABLE_KEY=pk_test_web" > "$dir/apps/web/.env"
echo "VITE_CLERK_PUBLISHABLE_KEY=pk_test_admin" > "$dir/apps/admin/.env"
printf '{\n "version": 2,\n "dotenv": [],\n "external": []\n}\n' > "$dir/.secrets.json"
cd "$dir"
}
declare_pnpm() {
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*"
- "packages/*"
YAML
echo '{"name":"mono","private":true}' > package.json
}
declare_npm() {
echo '{"name":"mono","private":true,"workspaces":["apps/*"]}' > package.json
}
declare_yarn_object() {
echo '{"name":"mono","private":true,"workspaces":{"packages":["apps/*"]}}' > package.json
}
# ─── the pnpm gap (the reported bug) ───────────────────────────────────
@test "EGB-1232: push discovers workspace env files in a pnpm monorepo" {
init_with_remote
make_monorepo pnpmrepo
declare_pnpm
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"apps/admin/.env"* ]] || false
}
@test "EGB-1232: push actually vaults pnpm workspace env files" {
init_with_remote
make_monorepo pnpmreal
declare_pnpm
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
rm -f apps/web/.env apps/admin/.env
run "$SECRETS_BIN" pull
[ "$status" -eq 0 ]
[ -f apps/web/.env ]
[ -f apps/admin/.env ]
# The value that was empty in the .env.example — the actual impact.
[[ "$(cat apps/web/.env)" == *"pk_test_web"* ]] || false
}
@test "EGB-1232: push -w works on a pnpm monorepo" {
init_with_remote
make_monorepo pnpmw
declare_pnpm
run "$SECRETS_BIN" push -w
[ "$status" -eq 0 ]
[[ "$output" != *"No workspaces field"* ]] || false
}
@test "EGB-1232: pnpm-workspace.yaml is read without requiring a package.json" {
init_with_remote
make_monorepo pnpmnopkg
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
}
# ─── pnpm YAML parsing edge cases ──────────────────────────────────────
@test "EGB-1232: pnpm parsing stops at the next top-level key" {
init_with_remote
make_monorepo pnpmkeys
# pnpm 10+ carries sibling top-level keys. They must not leak in as globs.
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*"
onlyBuiltDependencies:
- esbuild
- sharp
catalog:
react: ^18.0.0
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" != *"esbuild"* ]] || false
[[ "$output" != *"sharp"* ]] || false
}
@test "EGB-1232: pnpm parsing handles quotes, bare entries, and comments" {
init_with_remote
make_monorepo pnpmquotes
mkdir -p docs && echo "DOCS=1" > docs/.env
cat > pnpm-workspace.yaml <<'YAML'
# which packages belong to this workspace
packages:
- "apps/*" # double quoted, with a trailing comment
- 'docs' # single quoted, no glob
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"docs/.env"* ]] || false
}
@test "EGB-1232: a pnpm-workspace.yaml with no packages key discovers nothing and says so" {
init_with_remote
make_monorepo pnpmempty
cat > pnpm-workspace.yaml <<'YAML'
onlyBuiltDependencies:
- esbuild
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"pnpm-workspace.yaml"* ]] || false
}
# ─── the yarn object-form defect ───────────────────────────────────────
@test "EGB-1232: yarn's object workspaces form is expanded, not iterated" {
init_with_remote
make_monorepo yarnobj
declare_yarn_object
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"apps/admin/.env"* ]] || false
}
@test "EGB-1232: yarn object form works under -w" {
init_with_remote
make_monorepo yarnobjw
declare_yarn_object
run "$SECRETS_BIN" push -w
[ "$status" -eq 0 ]
[[ "$output" != *"No workspaces field"* ]] || false
}
# ─── npm regression guard ──────────────────────────────────────────────
@test "EGB-1232: npm's array workspaces form still works" {
init_with_remote
make_monorepo npmarr
declare_npm
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"apps/admin/.env"* ]] || false
}
@test "EGB-1232: package.json workspaces wins when both sources are present" {
init_with_remote
make_monorepo bothsrc
mkdir -p only-pnpm && echo "P=1" > only-pnpm/.env
echo '{"name":"mono","workspaces":["apps/*"]}' > package.json
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "only-pnpm"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" != *"only-pnpm/.env"* ]] || false
}
# ─── diagnostics: the silence is the bug ───────────────────────────────
@test "EGB-1232: a monorepo-shaped root with no readable workspace source warns" {
init_with_remote
make_monorepo shaped
# Looks like a monorepo (pnpm-workspace.yaml present) but declares nothing.
echo "# nothing useful here" > pnpm-workspace.yaml
# A root .env so the push SUCCEEDS — the dangerous case is a green push
# that silently skipped every workspace, which is what bit thatsbait.
echo "ROOT=1" > .env
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" == *"WARNING"* ]] || false
[[ "$output" == *"workspace"* ]] || false
}
@test "EGB-1232: the warning tells you how to recover" {
init_with_remote
make_monorepo shapedfix
echo "# nothing useful here" > pnpm-workspace.yaml
echo "ROOT=1" > .env
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" == *"secrets add"* ]] || false
}
@test "EGB-1232: a plain single-package project never warns about workspaces" {
init_with_remote
create_project_dir plainproj
printf '{\n "version": 2,\n "dotenv": [],\n "external": []\n}\n' > .secrets.json
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"workspace"* ]] || false
}
@test "EGB-1232: push -w names pnpm-workspace.yaml when that is the file present" {
init_with_remote
make_monorepo wnames
echo '{"name":"mono"}' > package.json
echo "# no packages key" > pnpm-workspace.yaml
run "$SECRETS_BIN" push -w
[ "$status" -ne 0 ]
[[ "$output" == *"pnpm-workspace.yaml"* ]] || false
}
@test "EGB-1232: push -w still names package.json when no pnpm file exists" {
init_with_remote
make_monorepo wnamespkg
echo '{"name":"mono"}' > package.json
run "$SECRETS_BIN" push -w
[ "$status" -ne 0 ]
[[ "$output" == *"package.json"* ]] || false
}
# ─── path safety ───────────────────────────────────────────────────────
@test "EGB-1232: a pnpm packages entry cannot escape the project root" {
init_with_remote
make_monorepo escapee
echo "OUTSIDE=1" > "$WORK_DIR/outside.env"
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "../"
- "/etc"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" != *"outside.env"* ]] || false
[[ "$output" != *"/etc/"* ]] || false
}
@test "EGB-1232: a pnpm packages entry with shell metacharacters is refused" {
init_with_remote
make_monorepo metachars
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*; touch /tmp/egb1232-pwned"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[ ! -f /tmp/egb1232-pwned ]
}