From 884da0965cfebc99bd203c52ab4a268b1747a0fa Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Sun, 7 Jun 2026 08:32:17 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20manifest-aware=20push=20=E2=80=94=20gen?= =?UTF-8?q?erator=20auto-add,=20autoAdd=20toggle,=20--frozen/--dry-run=20(?= =?UTF-8?q?EGB-677=20stage=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - push syncs FROM the manifest; v1 store layout unchanged (nested entries land at /.age, same shape -w always used) - discovery (root globs + quiet package.json workspace re-scan when a manifest exists) feeds the manifest as a generator; new files auto-add with ==> notice + undo guidance - options.autoAdd committed toggle (default ON when absent); explicit false warns on undeclared files instead of enrolling them - push --frozen: declared-only for one invocation; push --dry-run: reports would-add/would-sync, touches nothing - bootstrap ordering: manifest written only after >=1 blob encrypts - declared-but-missing warns and continues; unsafe manifest path dies - jq // falsy gotcha: explicit autoAdd:false compared directly --- secrets | 147 ++++++++++++++++++++++++++++++++++++++++++++- test/manifest.bats | 121 +++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 3 deletions(-) diff --git a/secrets b/secrets index 99fdae4..e1d4f2c 100755 --- a/secrets +++ b/secrets @@ -833,6 +833,27 @@ cmd_add() { info "Commit the manifest so other machines pick it up. To undo: edit $SECRETS_JSON_NAME and remove the entry." } +# Quietly emit "ws-dir/basename" for every env file in a package.json +# workspace under . Emits nothing (and never dies) when is +# not a workspace monorepo or jq is unavailable — plain `push` calls this +# speculatively so a new workspace's env files keep getting discovered +# after the one-time --workspaces generator run (EGB-677 E13). +_maybe_workspace_env_files() { + local root="$1" + [ -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 + if collect_env_files "$root/$ws"; then + for f in "${COLLECTED_FILES[@]}"; do + printf '%s/%s\n' "$ws" "$(basename "$f")" + done + fi + done < <(get_workspaces "$root") +} + # ─── End manifest ────────────────────────────────────────────────────── # Read package.json workspaces and expand globs to actual directories. @@ -1033,7 +1054,22 @@ commit_and_push_secrets() { fi } +# Manifest-aware push (EGB-677 stage 1). Discovery (root globs + a quiet +# package.json workspace re-scan when a manifest exists) feeds the +# manifest as a GENERATOR; the sync itself runs FROM the manifest. The +# v1 store layout is unchanged: root files land at /.age, +# nested entries at /.age (same shape -w always used). cmd_push() { + local frozen=false dry_run=false explicit_project="" + while [ $# -gt 0 ]; do + case "$1" in + --frozen) frozen=true; shift ;; + --dry-run) dry_run=true; shift ;; + -*) die "Unknown push flag: $1. Usage: secrets push [--frozen] [--dry-run] [project]" ;; + *) explicit_project="$1"; shift ;; + esac + done + check_cmd age check_cmd git resolve_store @@ -1041,20 +1077,124 @@ cmd_push() { check_key local project - project=$(derive_project_name "${1:-}") + project=$(derive_project_name "$explicit_project") info "Pushing secrets for project: $project" echo_store_if_non_default local pubkey pubkey=$(get_pubkey) + # ── Manifest read (validated; absence = bootstrap) ── + local manifest="$PWD/$SECRETS_JSON_NAME" + local have_manifest=false auto_add=true declared="" + if [ -e "$manifest" ]; then + _check_manifest_file "$manifest" + have_manifest=true + declared=$(jq -r '.dotenv // [] | .[]' "$manifest") + local d + while IFS= read -r d; do + [ -n "$d" ] || continue + _validate_dotenv_rel_path "$d" \ + || die "Refusing unsafe dotenv path in $SECRETS_JSON_NAME (paths must be project-relative): $d" + done <<< "$declared" + # NB: jq's // treats false as empty, so `.options.autoAdd // true` + # would silently flip an explicit false back to true. Compare directly. + auto_add=$(jq -r '.options.autoAdd | if . == false then "false" else "true" end' "$manifest") + fi + [ "$frozen" = true ] && auto_add=false + + # ── Discovery: root globs + workspace re-scan (manifest projects) ── + local discovered="" f + if collect_env_files "$PWD"; then + for f in "${COLLECTED_FILES[@]}"; do + discovered="$discovered$(basename "$f")"$'\n' + done + fi + if [ "$have_manifest" = true ]; then + discovered="$discovered$(_maybe_workspace_env_files "$PWD")"$'\n' + fi + + # to_add = discovered − declared (deduped; pure bash 3.2, no assoc arrays) + local to_add="" e known + while IFS= read -r e; do + [ -n "$e" ] || continue + known=0 + while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$declared" + [ "$known" -eq 1 ] && continue + while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$to_add" + [ "$known" -eq 1 ] && continue + to_add="$to_add$e"$'\n' + done <<< "$discovered" + + if [ "$dry_run" = true ]; then + info "Dry run — nothing encrypted, nothing written." + if [ -n "$to_add" ]; then + echo "Would add to $SECRETS_JSON_NAME:" + while IFS= read -r e; do [ -n "$e" ] && echo " $e"; done <<< "$to_add" + else + echo "Nothing new to add to $SECRETS_JSON_NAME." + fi + if [ -n "$declared" ]; then + echo "Would sync (declared):" + while IFS= read -r e; do [ -n "$e" ] && echo " $e"; done <<< "$declared" + fi + return 0 + fi + + # ── Build the sync list ── + local sync_list="$declared" + if [ "$auto_add" = true ] || [ "$have_manifest" = false ]; then + sync_list="$declared"$'\n'"$to_add" + else + while IFS= read -r e; do + [ -n "$e" ] || continue + echo "WARNING: '$e' is not declared in $SECRETS_JSON_NAME and autoAdd is off — not synced. Run: secrets add $e" >&2 + done <<< "$to_add" + fi + + # ── Encrypt FROM the (effective) manifest ── + local count=0 rel + while IFS= read -r rel; do + [ -n "$rel" ] || continue + if [ ! -f "$PWD/$rel" ]; then + echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but not found in $PWD — skipping." >&2 + continue + fi + case "$rel" in + */*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;; + *) mkdir -p "$SECRETS_DIR/$project" ;; + esac + age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel" + echo " $rel" + count=$((count + 1)) + done <<< "$sync_list" + [ "$count" -gt 0 ] && info "$project: $count file(s)" + local did=0 - if push_dir_to_project "$PWD" "$project" "$pubkey"; then did=1; fi + [ "$count" -gt 0 ] && did=1 if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi if [ "$did" -eq 0 ]; then die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD" fi + # ── Manifest write AFTER successful encryption (bootstrap ordering) ── + if [ "$count" -gt 0 ] && [ -n "$to_add" ] && [ "$frozen" = false ] \ + && { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then + local add_json + add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))') + if [ "$have_manifest" = true ]; then + jq --argjson add "$add_json" '.dotenv = ((.dotenv // []) + $add)' "$manifest" \ + | _write_manifest_canonical "$manifest" || die "Failed to update $manifest" + else + jq -n --argjson add "$add_json" '{version: '"$MANIFEST_VERSION"', dotenv: $add}' \ + | _write_manifest_canonical "$manifest" || die "Failed to write $manifest" + fi + while IFS= read -r e; do + [ -n "$e" ] && info "Added '$e' to $SECRETS_JSON_NAME" + done <<< "$to_add" + info "Commit the manifest so other machines pick it up. To undo an entry: edit $SECRETS_JSON_NAME (or use 'secrets push --frozen' to skip auto-add)." + fi + commit_and_push_secrets "update $project" } @@ -1694,7 +1834,8 @@ case "${1:-help}" in if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then cmd_push_workspaces else - cmd_push "${2:-}" + shift + cmd_push "$@" fi ;; pull) diff --git a/test/manifest.bats b/test/manifest.bats index eb3e7d2..9800c72 100644 --- a/test/manifest.bats +++ b/test/manifest.bats @@ -123,3 +123,124 @@ load test_helper [ "$status" -eq 1 ] [[ "$output" == *"symlink"* ]] || false } + +# ─── B: push from manifest — generators, autoAdd, --frozen/--dry-run ─── + +@test "push with manifest syncs nested declared file into v1 store layout" { + init_with_remote + create_project_dir nestproj + mkdir -p packages/web + echo "K=v" > packages/web/.env.development + "$SECRETS_BIN" add packages/web/.env.development >/dev/null + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/nestproj/packages/web/.env.development.age" ] +} + +@test "push auto-adds newly discovered root files to an existing manifest" { + init_with_remote + create_project_dir autoproj + "$SECRETS_BIN" add .env >/dev/null + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [[ "$output" == *"Added"* ]] || false + run jq -r '.dotenv | index(".env.staging") != null' .secrets.json + [ "$output" = "true" ] + [ -f "$SECRETS_DIR/autoproj/.env.staging.age" ] +} + +@test "bootstrap: plain push creates the manifest from discovered files" { + init_with_remote + create_project_dir bootproj + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -f ".secrets.json" ] + run jq -r '.dotenv | length' .secrets.json + [ "$output" = "2" ] +} + +@test "failed push leaves no bootstrap manifest behind" { + init_with_remote + mkdir -p "$WORK_DIR/emptyproj" + cd "$WORK_DIR/emptyproj" + run "$SECRETS_BIN" push + [ "$status" -eq 1 ] + [ ! -f ".secrets.json" ] +} + +@test "autoAdd=false: undeclared discovered file is warned about, not added or synced" { + init_with_remote + create_project_dir noaddproj + printf '{"version":2,"options":{"autoAdd":false},"dotenv":[".env"]}\n' > .secrets.json + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [[ "$output" == *"not declared"* ]] || false + run jq -r '.dotenv | index(".env.staging") != null' .secrets.json + [ "$output" = "false" ] + [ -f "$SECRETS_DIR/noaddproj/.env.age" ] + [ ! -f "$SECRETS_DIR/noaddproj/.env.staging.age" ] +} + +@test "push --frozen skips auto-add even when autoAdd is on" { + init_with_remote + create_project_dir frozenproj + "$SECRETS_BIN" add .env >/dev/null + run "$SECRETS_BIN" push --frozen + [ "$status" -eq 0 ] + run jq -r '.dotenv | index(".env.staging") != null' .secrets.json + [ "$output" = "false" ] + [ ! -f "$SECRETS_DIR/frozenproj/.env.staging.age" ] + # declared entry still synced under the REAL project name + [ -f "$SECRETS_DIR/frozenproj/.env.age" ] +} + +@test "push --dry-run reports would-add entries and changes nothing" { + init_with_remote + create_project_dir dryproj + "$SECRETS_BIN" add .env >/dev/null + cp .secrets.json "$TEST_TMPDIR/manifest-before.json" + run "$SECRETS_BIN" push --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *".env.staging"* ]] || false + cmp -s .secrets.json "$TEST_TMPDIR/manifest-before.json" + [ ! -f "$SECRETS_DIR/dryproj/.env.age" ] + # nothing committed to the store at all + [ "$(git -C "$SECRETS_DIR" rev-list --count HEAD)" -eq 1 ] +} + +@test "plain push re-scans package.json workspaces when a manifest exists" { + init_with_remote + local mono="$WORK_DIR/wsproj" + mkdir -p "$mono/packages/api" + printf '{"workspaces": ["packages/*"]}\n' > "$mono/package.json" + echo "ROOT=1" > "$mono/.env" + echo "API=1" > "$mono/packages/api/.dev.vars" + git init "$mono" >/dev/null 2>&1 + cd "$mono" + "$SECRETS_BIN" add .env >/dev/null + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + run jq -r '.dotenv | index("packages/api/.dev.vars") != null' .secrets.json + [ "$output" = "true" ] + [ -f "$SECRETS_DIR/wsproj/packages/api/.dev.vars.age" ] +} + +@test "declared-but-missing file warns and push continues" { + init_with_remote + create_project_dir missproj + "$SECRETS_BIN" add .env >/dev/null + printf '{"version":2,"dotenv":[".env",".env.gone"]}\n' > .secrets.json + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [[ "$output" == *".env.gone"* ]] || false + [ -f "$SECRETS_DIR/missproj/.env.age" ] +} + +@test "unsafe dotenv entry in a committed manifest dies on push" { + init_with_remote + create_project_dir evilproj + printf '{"version":2,"dotenv":["../escape/.env"]}\n' > .secrets.json + run "$SECRETS_BIN" push + [ "$status" -eq 1 ] + [[ "$output" == *"project-relative"* ]] || false +}