feat: manifest-aware push — generator auto-add, autoAdd toggle, --frozen/--dry-run (EGB-677 stage 1)
- push syncs FROM the manifest; v1 store layout unchanged (nested entries land at <project>/<relpath>.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
This commit is contained in:
parent
18018dbd3b
commit
884da0965c
2 changed files with 265 additions and 3 deletions
147
secrets
147
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."
|
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 <root>. Emits nothing (and never dies) when <root> 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 ──────────────────────────────────────────────────────
|
# ─── End manifest ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Read package.json workspaces and expand globs to actual directories.
|
# Read package.json workspaces and expand globs to actual directories.
|
||||||
|
|
@ -1033,7 +1054,22 @@ commit_and_push_secrets() {
|
||||||
fi
|
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 <project>/<name>.age,
|
||||||
|
# nested entries at <project>/<relpath>.age (same shape -w always used).
|
||||||
cmd_push() {
|
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 age
|
||||||
check_cmd git
|
check_cmd git
|
||||||
resolve_store
|
resolve_store
|
||||||
|
|
@ -1041,20 +1077,124 @@ cmd_push() {
|
||||||
check_key
|
check_key
|
||||||
|
|
||||||
local project
|
local project
|
||||||
project=$(derive_project_name "${1:-}")
|
project=$(derive_project_name "$explicit_project")
|
||||||
info "Pushing secrets for project: $project"
|
info "Pushing secrets for project: $project"
|
||||||
echo_store_if_non_default
|
echo_store_if_non_default
|
||||||
|
|
||||||
local pubkey
|
local pubkey
|
||||||
pubkey=$(get_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
|
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 push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
|
||||||
if [ "$did" -eq 0 ]; then
|
if [ "$did" -eq 0 ]; then
|
||||||
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
|
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
|
||||||
fi
|
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"
|
commit_and_push_secrets "update $project"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1694,7 +1834,8 @@ case "${1:-help}" in
|
||||||
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
|
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
|
||||||
cmd_push_workspaces
|
cmd_push_workspaces
|
||||||
else
|
else
|
||||||
cmd_push "${2:-}"
|
shift
|
||||||
|
cmd_push "$@"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
pull)
|
pull)
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,124 @@ load test_helper
|
||||||
[ "$status" -eq 1 ]
|
[ "$status" -eq 1 ]
|
||||||
[[ "$output" == *"symlink"* ]] || false
|
[[ "$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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue