Merge pull request 'v0.7.7.0 fix: discover workspaces in pnpm and yarn monorepos (EGB-1232)' (#16) from brian/egb-1232-pnpm-workspace-discovery into main
This commit is contained in:
commit
c83935a92f
6 changed files with 517 additions and 14 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -5,6 +5,47 @@ 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
|
## [0.7.6.0] - 2026-09-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
|
||||||
- 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.
|
- 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: `--workspaces` flag reads `package.json` workspaces, requires `jq`
|
- 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).
|
||||||
- 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 —
|
||||||
|
|
@ -99,6 +99,7 @@ test/
|
||||||
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)
|
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
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -425,10 +425,10 @@ git commit -am "switch to personal secrets"
|
||||||
|
|
||||||
### Monorepo support
|
### Monorepo support
|
||||||
|
|
||||||
For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once:
|
For projects with multiple packages, add the `-w` flag to operate on all workspaces at once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
|
cd ~/myapp # npm/yarn workspaces, or a pnpm-workspace.yaml
|
||||||
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,7 +445,22 @@ Inside `~/.secrets/`, workspace secrets are organized by path:
|
||||||
apps/api/.env.age # api workspace
|
apps/api/.env.age # api workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires `jq` (`brew install jq`).
|
**Where workspaces are declared.** All three package managers are supported:
|
||||||
|
|
||||||
|
| 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.6.0
|
0.7.7.0
|
||||||
|
|
|
||||||
164
secrets
164
secrets
|
|
@ -986,6 +986,14 @@ 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
|
||||||
|
|
@ -1233,9 +1241,22 @@ _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"
|
||||||
[ -f "$root/package.json" ] || return 0
|
# EGB-1232: this used to require package.json + a `.workspaces` key and
|
||||||
command -v jq >/dev/null 2>&1 || return 0
|
# `return 0` otherwise — so on every pnpm repo the guard fired immediately,
|
||||||
jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 || return 0
|
# auto-discovery was inert, and `push` printed "Nothing new to add",
|
||||||
|
# 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
|
||||||
|
|
@ -1249,17 +1270,142 @@ _maybe_workspace_env_files() {
|
||||||
|
|
||||||
# ─── End manifest ──────────────────────────────────────────────────────
|
# ─── End manifest ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Read package.json workspaces and expand globs to actual directories.
|
# ─── Workspace pattern sources (EGB-1232) ──────────────────────────────
|
||||||
|
#
|
||||||
|
# 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=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null)
|
patterns=$(_workspace_patterns "$root")
|
||||||
[ -n "$patterns" ] || die "No workspaces field in $pkg"
|
if [ -z "$patterns" ]; then
|
||||||
|
# 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"
|
||||||
|
|
|
||||||
300
test/workspaces.bats
Normal file
300
test/workspaces.bats
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
#!/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