diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bee6fb..7e7cde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,47 +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/), 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 @@ -475,6 +434,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. -[0.1.1.0]: https://git.dev.egbt.com/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.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.0 +[0.1.1.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.1.0 +[0.1.0.1]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.1 +[0.1.0.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index a0261b8..ff8405f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - Verify (EGB-698): `secrets verify` is a read-only integrity check. Default mode (current project) cross-checks `$PWD/.secrets.json` against `$SECRETS_DIR//` 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 -- 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`) - 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 — @@ -99,7 +99,6 @@ test/ migrate.bats # EGB-703 store-format-v2 migration tests (35 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) test_helper.bash # Shared setup/teardown README.md # User-facing documentation @@ -138,43 +137,31 @@ Key design decisions (all driven by /autoplan review): ## 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) - Release model: merge to `main` is the release. Optionally tagged with `v`. - Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL. - Staging: none. - 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` -(migrated off Codeberg 2026-09-08). `gh`/`glab` do NOT work here. Use `tea` -(login name: `egbt`, user `brian`) for forge operations when a skill's +The remote is Codeberg (Forgejo) — `gh`/`glab` do NOT work here. Use `tea` +(login name: `codeberg`, user `egbt`) for forge operations when a skill's platform detection comes up "unknown": -**Always pass `--login egbt --repo egbt/secrets` explicitly.** `tea`'s repo -autodetection fails here ("remote repository required"), and this machine also -has a leftover `codeberg` login pointing at the *old* forge -(`https://codeberg.org`) that `tea` will silently fall back to in +**Always pass `--login codeberg --repo egbt/secrets` explicitly.** `tea`'s +repo autodetection fails here ("remote repository required"), and this machine +also has an `egbt` login pointing at a *different* forge +(`https://git.dev.egbt.com`) 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 --title ... --description ...` / `tea pr merge --login egbt --repo egbt/secrets` -- Releases: `tea releases create --login egbt --repo egbt/secrets --tag v --title "v" --note ...` +- PRs: `tea pr create --login codeberg --repo egbt/secrets --base main --head --title ... --description ...` / `tea pr merge --login codeberg --repo egbt/secrets` +- Releases: `tea releases create --login codeberg --repo egbt/secrets --tag v --title "v" --note ...` (convention: one release per tag, title `v`) -- Issues/status: `tea issues --login egbt --repo egbt/secrets`, `tea pr list --login egbt --repo egbt/secrets` -- **SSH is on port 2222**, not 22 (port 22 is the host's own sshd). Clone/remote - 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. +- Issues/status: `tea issues --login codeberg --repo egbt/secrets`, `tea pr list --login codeberg --repo egbt/secrets` +- No CI on this repo: the bats suite run locally is the merge gate. ## Environment variable diff --git a/README.md b/README.md index acfd2b4..f838ad5 100644 --- a/README.md +++ b/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. ```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 ./install.sh ``` @@ -82,7 +82,7 @@ this machine with one of the two flows below. ```bash # 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: secrets init --remote git@github.com:/my-secrets.git @@ -108,7 +108,7 @@ On a second machine, a desktop, or a teammate's laptop: ```bash # 1. Clone the tool and run the bootstrap (as in Setup above) -git clone https://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 # 2. Get key.txt onto this machine (the one manual, out-of-band step): @@ -425,10 +425,10 @@ git commit -am "switch to personal secrets" ### 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 -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 pull -w # decrypts into root + each workspace directory secrets clear -w # clears secrets from root + each workspace @@ -445,22 +445,7 @@ Inside `~/.secrets/`, workspace secrets are organized by path: apps/api/.env.age # api workspace ``` -**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. +Requires `jq` (`brew install jq`). ### External files (Gradle properties) diff --git a/VERSION b/VERSION index d5ba5b5..29e2792 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.7.0 +0.7.6.0 diff --git a/secrets b/secrets index 00ce9b3..8d947dc 100755 --- a/secrets +++ b/secrets @@ -986,14 +986,6 @@ pull_external_files() { SECRETS_JSON_NAME=".secrets.json" 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 # add`). The path is attacker-influenced in team repos (committed file), # 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). _maybe_workspace_env_files() { local root="$1" - # EGB-1232: this used to require package.json + a `.workspaces` key and - # `return 0` otherwise — so on every pnpm repo the guard fired immediately, - # 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 " >&2 - fi - return 0 - fi + [ -f "$root/package.json" ] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 || return 0 local ws f while IFS= read -r ws; do [ -n "$ws" ] || continue @@ -1270,142 +1249,17 @@ _maybe_workspace_env_files() { # ─── End manifest ────────────────────────────────────────────────────── -# ─── 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. +# Read package.json workspaces and expand globs to actual directories. # Prints one workspace path per line (relative to the monorepo root). get_workspaces() { local root="$1" + local pkg="$root/package.json" + [ -f "$pkg" ] || die "No package.json found in $root" + check_cmd jq local patterns - patterns=$(_workspace_patterns "$root") - 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 + patterns=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null) + [ -n "$patterns" ] || die "No workspaces field in $pkg" # Expand each glob pattern relative to root local old_dir="$PWD" diff --git a/test/workspaces.bats b/test/workspaces.bats deleted file mode 100644 index 280196f..0000000 --- a/test/workspaces.bats +++ /dev/null @@ -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 ] -}