Compare commits
No commits in common. "main" and "brian/egb-283-secrets-multi-recipient-age-encryption-multiple-keys-per" have entirely different histories.
main
...
brian/egb-
7 changed files with 35 additions and 1230 deletions
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -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, F1–F5 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, F1–F5 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
|
||||||
|
|
|
||||||
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
56
README.md
56
README.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.7.7.0
|
0.7.5.0
|
||||||
|
|
|
||||||
404
secrets
404
secrets
|
|
@ -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." ;;
|
||||||
|
|
|
||||||
373
test/sync.bats
373
test/sync.bats
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 ]
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue