feat: manifest-driven pull — nested restore, restore-time rail, empty no-op (EGB-677 stage 1)

- pull with .secrets.json restores exactly the declared entries (nested
  paths get mkdir -p); stray store blobs are not restored
- dotenv rail re-runs at restore time: unsafe entries warn+skip (pull
  never dies on one bad entry), missing blobs warn with a directed hint
- empty manifest = warn no-op instead of a confusing 'not found' death
- manifest-less projects keep the legacy glob pull verbatim
This commit is contained in:
Brian Majewski 2026-06-07 08:49:02 -07:00
parent d7e1400487
commit 0049584d9b
2 changed files with 131 additions and 0 deletions

48
secrets
View file

@ -1414,6 +1414,54 @@ cmd_pull() {
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
fi
# ── Manifest-driven pull (EGB-677 stage 1) ──
# With a .secrets.json present, the manifest decides what restores and
# where (nested entries get their directories created). The dotenv rail
# runs again at restore time — warn+skip on pull, never die, so one bad
# entry can't block the rest of the restore.
local manifest="$PWD/$SECRETS_JSON_NAME"
if [ -e "$manifest" ]; then
_check_manifest_file "$manifest"
local declared n_external
declared=$(jq -r '.dotenv // [] | .[]' "$manifest")
n_external=$(jq -r '.external // [] | length' "$manifest")
if [ -z "$declared" ] && [ "$n_external" -eq 0 ]; then
echo "WARNING: $SECRETS_JSON_NAME declares nothing to pull (empty manifest). Run 'secrets push' on a machine that has the files." >&2
ensure_store_protections
return 0
fi
if [ -n "$declared" ] && [ ! -d "$SECRETS_DIR/$project" ]; then
die "Project '$project' not found. Run: secrets list"
fi
local count=0 rel
while IFS= read -r rel; do
[ -n "$rel" ] || continue
if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then
echo "WARNING: skipping unsafe dotenv path from $SECRETS_JSON_NAME: $rel" >&2
continue
fi
local blob="$SECRETS_DIR/$project/${rel}.age"
if [ ! -f "$blob" ]; then
echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but has no encrypted data in the store yet. Run 'secrets push' on a machine that has it. Skipping." >&2
continue
fi
case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"
if [ ! -s "$target_dir/$rel" ]; then
echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)"
fi
count=$((count + 1))
done <<< "$declared"
info "Decrypted $count file(s) into $target_dir"
pull_external_files "$PWD" "$project"
ensure_store_protections
return 0
fi
# ── Legacy glob pull (manifest-less projects; unchanged) ──
# Check project exists
if [ ! -d "$SECRETS_DIR/$project" ]; then
die "Project '$project' not found. Run: secrets list"