#!/usr/bin/env bash
# =============================================================================
# secrets — read/edit gpg-encrypted credential sheets without ever writing
# plaintext to disk.
#
#   secrets list                  show every store in ~/.secrets
#   secrets view  [store]         decrypt to screen only (default: cockpit)
#   secrets edit  [store]         decrypt into RAM, edit, re-encrypt, wipe
#   secrets init  [store] [tpl]   create a new store, optionally from a named template
#   secrets rm    <store>         remove a store (mirrors are left alone)
#   secrets templates             list available templates
#   secrets template <new|edit|copy|rm|show|import> <name>
#   secrets scan                  find credential files loose on the disks
#   secrets archive <path> [dest] encrypt a file/dir into <name>.tar.gz.gpg
#   secrets restore <file.gpg> <dir>   unpack one back
#   secrets passwd [store]        re-encrypt under a NEW passphrase
#   secrets clean  [store]        shred any .prev backups (normally none: see below)
#   secrets mirrors [store]       list mirrored copies + which passphrase each needs
#   secrets rekey  [store]        re-encrypt OLD mirrored copies under the new passphrase
#   secrets purge  [store]        shred them instead (only if the old passphrase leaked)
#
# Settings — environment wins, else ~/.secrets/config, else these defaults:
#   SECRETS_DIR=~/.secrets            where the stores live
#   SECRETS_TEMPLATE=$SECRETS_DIR/CREDENTIALS_TEMPLATE.md
#   SECRETS_DEFAULT=cockpit           store used when you name none
#   SECRETS_ARCHIVES=$SECRETS_DIR/archives    where `archive` writes
#   SECRETS_MIRROR=                   COLON-SEPARATED LIST of mirror dirs (unset = off)
#   SECRETS_SCAN_DIRS=$HOME           roots `scan` sweeps (colon-separated)
#   SECRETS_KEEP=5                    dated copies kept per mirror
#   SECRETS_NO_PREV=1                 shred .prev on every save
#   SECRETS_CONF=$SECRETS_DIR/config  the config file itself
#
# Mirrors are a list, so:  add -> append ":/media/<you>/usb"
#                          remove -> delete that entry
#                          change -> edit the entry
# Make it permanent by putting the assignment in ~/.secrets/config.
#
# Multiple stores: `secrets init personal` -> ~/.secrets/personal.gpg, then
# `secrets edit personal`. Each has its own passphrase. Add as many as you like.
#
# Plaintext exists ONLY in /dev/shm (tmpfs = RAM) while the editor is open, in a
# 0700 dir, shredded on every exit path -- including Ctrl-C and editor crashes.
# =============================================================================
set -euo pipefail
SECRETS_VERSION="1.0.0"

# --- optional config file ---------------------------------------------------
# Everything below can be set in the environment. To make a setting stick
# without editing your shell rc, put the same assignments in this file; the
# environment still wins, so a one-off `SECRETS_MIRROR=... secrets edit` works.
SECRETS_CONF="${SECRETS_CONF:-${SECRETS_DIR:-$HOME/.secrets}/config}"
if [ -f "$SECRETS_CONF" ]; then
    # shellcheck disable=SC1090
    _env_dir="${SECRETS_DIR-}"; _env_mir="${SECRETS_MIRROR-}"; _env_keep="${SECRETS_KEEP-}"
    _env_prev="${SECRETS_NO_PREV-}"; _env_tpl="${SECRETS_TEMPLATE-}"
    _env_def="${SECRETS_DEFAULT-}"; _env_arc="${SECRETS_ARCHIVES-}"
    . "$SECRETS_CONF"
    [ -n "$_env_dir"  ] && SECRETS_DIR="$_env_dir"
    [ -n "$_env_mir"  ] && SECRETS_MIRROR="$_env_mir"
    [ -n "$_env_keep" ] && SECRETS_KEEP="$_env_keep"
    [ -n "$_env_prev" ] && SECRETS_NO_PREV="$_env_prev"
    [ -n "$_env_tpl"  ] && SECRETS_TEMPLATE="$_env_tpl"
    [ -n "$_env_def"  ] && SECRETS_DEFAULT="$_env_def"
    [ -n "$_env_arc"  ] && SECRETS_ARCHIVES="$_env_arc"
fi

DIR="${SECRETS_DIR:-$HOME/.secrets}"
TEMPLATE="${SECRETS_TEMPLATE:-$DIR/CREDENTIALS_TEMPLATE.md}"
# Templates are ordinary Markdown and hold NO secrets, so they live in the clear
# and can be edited, copied and shared freely.
TPLDIR="${SECRETS_TEMPLATES:-$DIR/templates}"
DEFAULT_STORE="${SECRETS_DEFAULT:-cockpit}"
ARCHIVES="${SECRETS_ARCHIVES:-$DIR/archives}"

# --- defaults, all overridable from the environment -------------------------
# SECRETS_NO_PREV=1  (default) every save shreds the .prev immediately, so a
#                    credential you delete from the sheet is gone at once
#                    instead of living on in a backup that opens with the same
#                    passphrase. Set to 0 to keep .prev as an undo.
# SECRETS_MIRROR     directory that receives a DATED copy of the ciphertext on
#                    every save. It is encrypted, so an untrusted medium (USB,
#                    second disk) is fine -- that is the point. Set to "" to
#                    disable. Point it at a USB stick to get a genuinely
#                    off-machine copy.
# SECRETS_KEEP       how many dated mirror copies to retain (default 5). These
#                    are your undo now that .prev is gone by default.
NO_PREV="${SECRETS_NO_PREV:-1}"
# Passphrase GENERATION. Symmetric gpg files carry no hint about which
# passphrase opens them, so after a rotation a mirror dir holds a mix that all
# looks identical. Every mirrored copy is tagged -gN, and N is bumped by
# `secrets passwd`. Not secret: it is a counter, kept in plain sight.
# Mirror locations you have STOPPED writing to. The tool still knows about them
# so `mirrors`, `rekey` and `purge` can reach the copies left behind there --
# an abandoned USB stick full of secrets is the whole problem.
RETIRED="${SECRETS_RETIRED-}"
# Roots that `scan` sweeps for stray credential files (colon-separated).
SCAN_DIRS="${SECRETS_SCAN_DIRS:-$HOME}"
# Generation is PER STORE: each store has its own passphrase, so a rotation of
# one must not renumber another's mirrored copies.
genfile(){ printf '%s/.generation.%s' "$DIR" "${1:-$DEFAULT_STORE}"; }
gen_now(){
    local gf; gf=$(genfile "${1:-$DEFAULT_STORE}")
    # one-time migration from the old single global counter
    if [ ! -f "$gf" ] && [ -f "$DIR/.generation" ]; then
        cp "$DIR/.generation" "$gf" 2>/dev/null && chmod 600 "$gf" 2>/dev/null
    fi
    [ -f "$gf" ] && cat "$gf" 2>/dev/null || echo 1
}

# Active mirrors + retired ones, as one colon list.
all_targets(){
    if [ -n "$RETIRED" ]; then printf '%s:%s' "${MIRROR}" "$RETIRED"
    else printf '%s' "$MIRROR"; fi
}
# Generation tag parsed out of a mirrored filename; 0 when untagged.
file_gen(){
    local b; b=$(basename "$1" .gpg); b="${b##*-g}"
    case "$b" in ''|*[!0-9]*) echo 0 ;; *) echo "$b" ;; esac
}
# Does this path look like removable media we should ask the user to plug in?
is_removable(){ case "$1" in /media/*|/mnt/*|/run/media/*) return 0 ;; *) return 1 ;; esac; }

# Resolve a template NAME (or path) to a file. Falls back to the legacy
# single-template path so nothing that already worked stops working.
tpl_path(){
    local n="${1:-}"
    [ -n "$n" ] || { printf '%s' "$TEMPLATE"; return 0; }
    case "$n" in
        */*) printf '%s' "$n" ;;                       # an explicit path
        *)   printf '%s/%s.md' "$TPLDIR" "${n%.md}" ;; # a name in the templates dir
    esac
}
tpl_name_ok(){ case "$1" in ''|*/*|.*) return 1 ;; *) return 0 ;; esac; }
MIRROR="${SECRETS_MIRROR-}"   # colon-separated LIST; empty = no mirroring
KEEP="${SECRETS_KEEP:-5}"
GPGOPTS=(--batch --yes --symmetric --cipher-algo AES256
         --s2k-mode 3 --s2k-count 65011712 --s2k-digest-algo SHA512)

die(){ echo "secrets: $*" >&2; exit 1; }

WORK=""
cleanup(){ [ -n "$WORK" ] && [ -d "$WORK" ] && { find "$WORK" -type f -exec shred -u {} + 2>/dev/null || true; rm -rf "$WORK" 2>/dev/null || true; }; return 0; }
trap cleanup EXIT INT TERM HUP
mkwork(){ [ -d /dev/shm ] || die "/dev/shm unavailable — refusing to write plaintext to disk"
          WORK=$(mktemp -d /dev/shm/.secrets_XXXXXXXX) || die "cannot create work dir"; chmod 700 "$WORK"; }

# store name -> path.  Rejects anything but a plain name (no traversal).
store_path(){
    local n="${1:-$DEFAULT_STORE}"
    [[ "$n" =~ ^[A-Za-z0-9._-]+$ ]] || die "bad store name: $n"
    printf '%s/%s.gpg' "$DIR" "${n%.gpg}"
}

encrypt_to(){  # $1=plaintext $2=dest — verify before replacing, never truncate
    local src="$1" dest="$2"
    gpg "${GPGOPTS[@]}" -o "$WORK/new.gpg" "$src" || die "encryption failed — $dest untouched"
    gpg --quiet --decrypt "$WORK/new.gpg" >/dev/null 2>&1 || die "re-encrypted file did not verify — $dest untouched"
    [ -f "$dest" ] && { cp -f "$dest" "$dest.prev"; chmod 600 "$dest.prev"; }
    mv -f "$WORK/new.gpg" "$dest"; chmod 600 "$dest"
    # Default: destroy the .prev straight away. Keeping it would mean a secret
    # you just deleted is still readable with the same passphrase.
    if [ "$NO_PREV" = 1 ] && [ -f "$dest.prev" ]; then
        shred -u "$dest.prev" 2>/dev/null || rm -f "$dest.prev"
    fi
    mirror_out "$dest"
}

# Drop a DATED copy of the ciphertext into $MIRROR and prune to $KEEP.
# Never fatal: a missing or unmounted mirror must not cost you the save you
# just made -- it warns and carries on.
mirror_out(){
    local dest="$1" base stamp target wrote=0 total=0
    [ -n "$MIRROR" ] || return 0
    base=$(basename "$dest" .gpg)
    # SECRETS_MIRROR is a colon-separated LIST, so you can keep a copy on the
    # second disk AND a USB AND a network share. Add one by appending ":path",
    # remove one by dropping it, change one by editing it.
    local IFS=:
    for target in $MIRROR; do
        [ -n "$target" ] || continue
        total=$((total+1))
        if ! mkdir -p "$target" 2>/dev/null; then
            echo "secrets: WARNING — mirror unavailable, skipped: $target" >&2; continue
        fi
        chmod 700 "$target" 2>/dev/null || true
        local g; g=$(gen_now "$base")
        stamp="$target/${base}-$(date +%Y-%m-%d_%H%M%S)-g${g}.gpg"
        local _n=0
        while [ -e "$stamp" ] && [ "$_n" -lt 50 ]; do
            _n=$((_n+1)); stamp="$target/${base}-$(date +%Y-%m-%d_%H%M%S)-${_n}-g${g}.gpg"
        done
        if cp -f "$dest" "$stamp" 2>/dev/null; then
            chmod 600 "$stamp"
            ls -1t "$target/${base}"-*.gpg 2>/dev/null | tail -n +$((KEEP+1)) \
                | while read -r old; do rm -f "$old"; done
            echo "  mirrored -> $stamp"
            wrote=$((wrote+1))
        else
            echo "secrets: WARNING — could not write to mirror: $target" >&2
        fi
    done
    if [ "$total" -gt 0 ] && [ "$wrote" = 0 ]; then
        echo "secrets: WARNING — store saved, but NO mirror copy was written" >&2
    fi
}


cmd="${1:-}"; shift || true

case "$cmd" in
  list)
    mkdir -p "$DIR"; chmod 700 "$DIR"
    found=0
    for f in "$DIR"/*.gpg; do
        [ -e "$f" ] || continue; found=1
        printf '  %-20s %8s bytes   modified %s\n' \
            "$(basename "${f%.gpg}")" "$(stat -c%s "$f")" "$(stat -c%y "$f" | cut -d. -f1)"
    done
    [ "$found" = 1 ] || echo "  (no stores yet — 'secrets init' creates the first)"
    ;;

  init)
    S=$(store_path "${1:-$DEFAULT_STORE}")
    [ -e "$S" ] && die "$S already exists — use 'secrets edit ${1:-$DEFAULT_STORE}'"
    mkdir -p "$DIR"; chmod 700 "$DIR"
    mkwork
    # optional 2nd arg: which template to start from
    TPL=$(tpl_path "${2:-}")
    if [ -f "$TPL" ]; then
        cp "$TPL" "$WORK/sheet.md"
        echo "  starting from template: $TPL"
    else
        [ -n "${2:-}" ] && die "no such template: ${2}  (try 'secrets templates')"
        printf '# %s — secrets\n\n' "${1:-$DEFAULT_STORE}" > "$WORK/sheet.md"
        echo "  no template found — starting from a blank sheet"
    fi
    chmod 600 "$WORK/sheet.md"
    "${EDITOR:-nano}" "$WORK/sheet.md"
    encrypt_to "$WORK/sheet.md" "$S"
    echo "created $S"
    ;;

  view)
    S=$(store_path "${1:-$DEFAULT_STORE}")
    [ -f "$S" ] || die "$S not found — run 'secrets init ${1:-$DEFAULT_STORE}'"
    gpg --quiet --decrypt "$S" | ${PAGER:-less -R}
    ;;

  edit)
    S=$(store_path "${1:-$DEFAULT_STORE}")
    [ -f "$S" ] || die "$S not found — run 'secrets init ${1:-$DEFAULT_STORE}'"
    mkwork
    gpg --quiet --decrypt -o "$WORK/sheet.md" "$S" || die "decrypt failed"
    chmod 600 "$WORK/sheet.md"
    before=$(md5sum < "$WORK/sheet.md")
    "${EDITOR:-nano}" "$WORK/sheet.md"
    [ "$before" = "$(md5sum < "$WORK/sheet.md")" ] && { echo "unchanged — not re-encrypting"; exit 0; }
    encrypt_to "$WORK/sheet.md" "$S"
    if [ "$NO_PREV" = 1 ]; then
        echo "updated $S  (no .prev kept — deletions take effect immediately)"
    else
        echo "updated $S  (previous kept as $(basename "$S").prev)"
    fi
    ;;

  scan)
    # Find credential-looking files loose on the disks. Prints NAMES AND MODES
    # ONLY -- never contents. Re-run whenever; new strays show up here.
    echo "Loose credential files (contents never read):"
    # Which roots to sweep. Defaults to your home dir; add data disks via
    # SECRETS_SCAN_DIRS (colon-separated) in the config.
    for d in $(printf '%s' "${SCAN_DIRS:-$HOME}" | tr ':' ' '); do
        [ -n "$d" ] && [ -d "$d" ] || continue
        find "$d" -maxdepth 4 -type f \
          \( -iname '*cred*' -o -iname '*secret*' -o -iname '*password*' -o -iname '*passwd*' \
             -o -iname '*token*' -o -iname '*api*key*' -o -iname '*.key' -o -iname '.env' \
             -o -iname '*.kdbx' -o -iname '*.enc' \) \
          ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/venv*/*' ! -path '*/Models/*' \
          ! -path '*/.cache/*' ! -path '*/.steam/*' ! -path '*/.icons/*' ! -path '*/Icons/*' \
          ! -path '*/Books/*' ! -path '*/.oh-my-zsh/*' ! -path '*/.gnupg/*' \
          ! -path '*Trust Tokens*' ! -path '*/.claude/sessions/*' \
          ! -name '*.epub' ! -name '*.pdf' ! -name '*.tga' ! -name '*.res' \
          ! -name '*.png' ! -name '*.svg' ! -name '*.gif' ! -name '*.mp3' ! -name '*.dll' \
          ! -name '*.exe' ! -name '*.apk' ! -name '*.zip' ! -name '*.gz' 2>/dev/null || true
        # `|| true`: find exits non-zero on ANY unreadable dir, and with
        # `set -e -o pipefail` that silently aborted the whole scan after the
        # first root -- it reported 13 files instead of 30 and looked like it had
        # finished. Never let a partial scan pass for a complete one.
    done | sort -u | while read -r f; do
        m=$(stat -c '%A' "$f" 2>/dev/null) || continue
        # mode string is 10 chars: -rwxrwxrwx. Other-perms are chars 8,9,10.
        case "$m" in
            ????????w?) risk=' *** WORLD-WRITABLE' ;;
            ???????r??) risk='  (world-readable)'  ;;
            *)          risk='' ;;
        esac
        printf '  %s %8s  %s%s\n' "$m" "$(stat -c%s "$f" 2>/dev/null)" "$f" "$risk"
    done
    echo
    echo "Record each in the SOURCE REGISTER of your sheet (secrets edit),"
    echo "then fold it in and delete or lock down the original."
    ;;

  mirrors)
    base="${1:-$DEFAULT_STORE}"
    [ -n "$(all_targets)" ] || die "no mirror locations configured"
    cur=$(gen_now "$base")
    echo "  current passphrase generation: g$cur   (store: $base)"
    echo
    ( IFS=:
      for target in $(all_targets); do
        [ -n "$target" ] || continue
        tag=""
        case ":$RETIRED:" in *":$target:"*) tag="   [RETIRED location]" ;; esac
        if [ ! -d "$target" ]; then
            if is_removable "$target"; then
                echo "  $target$tag  — NOT CONNECTED (removable: plug it in to reach these copies)"
            else
                echo "  $target$tag  — not present"
            fi
            continue
        fi
        echo "  $target$tag"
        found=0
        for f in "$target/${base}"-*.gpg; do
            [ -e "$f" ] || continue; found=1
            fg=$(basename "$f" .gpg); fg="${fg##*-g}"
            case "$fg" in ''|*[!0-9]*) fg='?' ;; esac
            if [ "$fg" = "$cur" ]; then note="current passphrase"
            elif [ "$fg" = '?' ]; then note="UNTAGGED — predates generation tracking"
            else note="needs the OLD passphrase from generation g$fg"; fi
            printf '      %-46s g%-3s %s\n' "$(basename "$f")" "$fg" "$note"
        done
        [ "$found" = 1 ] || echo "      (no copies of '$base' here yet)"
      done )
    echo
    [ -f "$DIR/rotations.log" ] && { echo "  rotation history:"; sed 's/^/      /' "$DIR/rotations.log"; echo; }
    echo "  open any copy:   gpg -d <file> > /dev/shm/sheet.md"
    echo "  (then type the passphrase for THAT file's generation)"
    ;;

  rekey)
    # Re-encrypt mirrored copies from OLDER passphrase generations under the
    # CURRENT one, keeping the history instead of destroying it. Files are
    # grouped by generation so you type each old passphrase once, not per file.
    base="${1:-$DEFAULT_STORE}"
    cur=$(gen_now "$base")
    targets=$(all_targets)
    [ -n "$targets" ] || die "no mirror locations configured"

    # Pass 1: survey. Report unreachable locations BEFORE asking for any
    # passphrase -- a USB you forgot to plug in should not be discovered
    # halfway through.
    missing=""; gens=""
    old_ifs=$IFS; IFS=:
    for t in $targets; do
        [ -n "$t" ] || continue
        if [ ! -d "$t" ]; then missing="$missing $t"; continue; fi
        for f in "$t/${base}"-*.gpg; do
            [ -e "$f" ] || continue
            g=$(file_gen "$f")
            [ "$g" -lt "$cur" ] 2>/dev/null || continue
            case " $gens " in *" $g "*) ;; *) gens="$gens $g" ;; esac
        done
    done
    IFS=$old_ifs

    if [ -n "$missing" ]; then
        echo "These locations are not reachable right now:"
        for m in $missing; do
            if is_removable "$m"; then echo "   $m   <-- removable: CONNECT THE DEVICE, then run rekey again"
            else echo "   $m   <-- not mounted or deleted"; fi
        done
        echo
    fi
    [ -n "$gens" ] || { echo "Nothing to rekey: every reachable copy of '$base' is already g$cur."; exit 0; }

    echo "Current generation: g$cur"
    echo "Old generations found:$gens"
    echo "You will be asked for each OLD passphrase once, then the current one."
    echo

    for g in $gens; do
        mkwork
        echo "--- generation g$g ---"
        n=0
        old_ifs=$IFS; IFS=:
        for t in $targets; do
            [ -n "$t" ] && [ -d "$t" ] || continue
            for f in "$t/${base}"-*.gpg; do
                [ -e "$f" ] || continue
                [ "$(file_gen "$f")" = "$g" ] || continue
                echo "  $f"
                if ! gpg --quiet --decrypt -o "$WORK/p.md" "$f" 2>/dev/null; then
                    echo "    SKIPPED — wrong passphrase or unreadable; file left as-is" >&2; continue
                fi
                if ! gpg "${GPGOPTS[@]}" -o "$WORK/n.gpg" "$WORK/p.md" 2>/dev/null; then
                    echo "    SKIPPED — re-encryption failed; file left as-is" >&2
                    rm -f "$WORK/p.md"; continue
                fi
                if ! gpg --quiet --decrypt "$WORK/n.gpg" >/dev/null 2>&1; then
                    echo "    SKIPPED — new file did not verify; file left as-is" >&2
                    rm -f "$WORK/p.md" "$WORK/n.gpg"; continue
                fi
                newname=$(dirname "$f")/$(basename "$f" .gpg | sed "s/-g${g}\$//")-g${cur}.gpg
                mv -f "$WORK/n.gpg" "$newname" && chmod 600 "$newname"
                [ "$newname" != "$f" ] && { shred -u "$f" 2>/dev/null || rm -f "$f"; }
                rm -f "$WORK/p.md"
                echo "    -> $(basename "$newname")"
                n=$((n+1))
            done
        done
        IFS=$old_ifs
        cleanup; WORK=""
        echo "  g$g: $n file(s) rekeyed"
    done
    echo
    echo "Done. 'secrets mirrors $base' to confirm."
    ;;

  purge)
    # Destroy mirror copies from OLDER passphrase generations. Prefer `rekey`,
    # which keeps them readable. Use this when the old passphrase LEAKED and
    # those copies are a live exposure.
    base="${1:-$DEFAULT_STORE}"
    cur=$(gen_now "$base")
    targets=$(all_targets)
    [ -n "$targets" ] || die "no mirror locations configured"
    echo "Current generation: g$cur. This SHREDS every copy of '$base' older than that."
    echo "Prefer 'secrets rekey $base' unless the old passphrase leaked."
    printf 'Type YES to proceed: '
    read -r ans
    [ "$ans" = YES ] || die "aborted — nothing removed"
    ( IFS=:
      for t in $targets; do
        [ -n "$t" ] && [ -d "$t" ] || continue
        for f in "$t/${base}"-*.gpg; do
            [ -e "$f" ] || continue
            g=$(file_gen "$f")
            if [ "$g" -lt "$cur" ] 2>/dev/null; then
                shred -u "$f" 2>/dev/null || rm -f "$f"
                echo "  shredded $f"
            fi
        done
      done )
    echo "done."
    ;;

  passwd)
    # Change the passphrase: decrypt with the old one, re-encrypt with a new one.
    # The agent caches symmetric passphrases, so clear it first or gpg will
    # silently reuse the old passphrase and you will think it changed when it
    # did not.
    S=$(store_path "${1:-$DEFAULT_STORE}")
    [ -f "$S" ] || die "$S not found"
    mkwork
    echo "Enter the CURRENT passphrase:"
    gpg --quiet --decrypt -o "$WORK/sheet.md" "$S" || die "decrypt failed — passphrase unchanged"
    chmod 600 "$WORK/sheet.md"
    gpgconf --reload gpg-agent 2>/dev/null || true
    echo "RELOADCACHE" | gpg-connect-agent >/dev/null 2>&1 || true
    echo "Now enter the NEW passphrase (twice):"
    sname=$(basename "${S%.gpg}")
    old_g=$(gen_now "$sname"); new_g=$((old_g+1))
    # Bump the generation only AFTER the store is actually re-encrypted.
    # encrypt_to verifies its output and dies leaving $S untouched if that
    # fails, so writing the counter first left the store on the OLD passphrase
    # while the counter claimed the new generation. Every mirror written after
    # that got a tag naming a passphrase it was not encrypted with -- and the
    # tag's whole job is to say which passphrase a copy needs. Observed: an
    # aborted rotation consumed g3, so the next real one logged "3 -> 4" from a
    # generation that never existed.
    encrypt_to "$WORK/sheet.md" "$S"
    gf=$(genfile "$sname"); echo "$new_g" > "$gf"; chmod 600 "$gf"
    printf '%s  %s  generation %s -> %s\n' "$(date +%Y-%m-%dT%H:%M:%S)" "$(basename "$S")" \
        "$old_g" "$new_g" >> "$DIR/rotations.log"
    chmod 600 "$DIR/rotations.log" 2>/dev/null || true
    echo "passphrase changed on $S  (now generation g$new_g)"
    echo
    echo "  Mirror copies tagged -g$old_g and older still open ONLY with the OLD"
    echo "  passphrase. They are listed by 'secrets mirrors'."
    echo "  Bring them up to the new passphrase (keeps the history):"
    echo "      secrets rekey $(basename "${S%.gpg}")"
    echo "  Or, ONLY if the old passphrase leaked, destroy them:"
    echo "      secrets purge $(basename "${S%.gpg}")"
    echo "  Retired/removable locations: list them in SECRETS_RETIRED and connect"
    echo "  the device first, or those copies stay on the old passphrase."
    ;;

  clean)
    # Every edit keeps a .prev. That is deliberate (a bad edit is recoverable),
    # but it means a secret you DELETED from the sheet is still sitting in .prev,
    # readable with the passphrase. Removing a compromised credential is not
    # finished until the .prev is gone too.
    n=0
    for f in "$DIR"/*.gpg.prev; do
        [ -e "$f" ] || continue
        if [ -n "${1:-}" ] && [ "$f" != "$(store_path "$1").prev" ]; then continue; fi
        shred -u "$f" 2>/dev/null || rm -f "$f"
        echo "  shredded $(basename "$f")"; n=$((n+1))
    done
    [ "$n" = 0 ] && echo "  no .prev backups to clean"
    ;;

  templates)
    mkdir -p "$TPLDIR"; chmod 700 "$TPLDIR"
    echo "  templates in $TPLDIR"
    found=0
    for f in "$TPLDIR"/*.md; do
        [ -e "$f" ] || continue; found=1
        desc=$(grep -m1 '^# ' "$f" 2>/dev/null | sed 's/^# //')
        printf '    %-22s %7s bytes  %s\n' "$(basename "$f" .md)" "$(stat -c%s "$f")" "${desc:-—}"
    done
    [ "$found" = 1 ] || echo "    (none yet — 'secrets template new <name>' creates one)"
    if [ -f "$TEMPLATE" ]; then
        echo
        echo "  legacy single template still present: $TEMPLATE"
        echo "  ('secrets template import' files it into $TPLDIR)"
    fi
    echo
    echo "  use one:  secrets init <store> <template>"
    ;;

  template)
    sub="${1:-}"; name="${2:-}"
    mkdir -p "$TPLDIR"; chmod 700 "$TPLDIR"
    case "$sub" in
      new)
        tpl_name_ok "$name" || die "usage: secrets template new <name>"
        f=$(tpl_path "$name"); [ -e "$f" ] && die "$f already exists — use 'template edit $name'"
        # Seed a new template with the skeleton, so a blank page never greets you.
        cat > "$f" <<'TPLEOF'
# <name> — secrets

> Template. Holds NO secrets: it is the blank form a store is created from.
> Copy a block per item. Keep "what breaks if this is lost" — at 3am it is the
> only line that matters.

## <Service>

| field | value | last rotated |
|---|---|---|
| URL / console | | |
| Username / account id | | |
| Password | | |
| 2FA method + recovery codes | | |
| API key / token | | |
| Scope / permissions granted | | |
| Billing owner + renewal date | | |
| What breaks if this is lost | | |
TPLEOF
        chmod 600 "$f"
        "${EDITOR:-nano}" "$f"
        echo "created $f"
        ;;
      edit)
        tpl_name_ok "$name" || die "usage: secrets template edit <name>"
        f=$(tpl_path "$name"); [ -f "$f" ] || die "no such template: $name"
        "${EDITOR:-nano}" "$f"; chmod 600 "$f"; echo "edited $f"
        ;;
      copy)
        src="${2:-}"; dst="${3:-}"
        tpl_name_ok "$src" && tpl_name_ok "$dst" || die "usage: secrets template copy <from> <to>"
        a=$(tpl_path "$src"); b=$(tpl_path "$dst")
        [ -f "$a" ] || die "no such template: $src"
        [ -e "$b" ] && die "$b already exists"
        cp "$a" "$b"; chmod 600 "$b"; echo "copied -> $b"
        ;;
      rm)
        tpl_name_ok "$name" || die "usage: secrets template rm <name>"
        f=$(tpl_path "$name"); [ -f "$f" ] || die "no such template: $name"
        printf "Delete template '%s'? Type YES: " "$name"; read -r ans
        [ "$ans" = YES ] || die "aborted"
        rm -f "$f"; echo "removed $f"
        ;;
      import)
        [ -f "$TEMPLATE" ] || die "no legacy template at $TEMPLATE"
        f=$(tpl_path "${name:-cockpit}")
        [ -e "$f" ] && die "$f already exists"
        cp "$TEMPLATE" "$f"; chmod 600 "$f"
        echo "imported $TEMPLATE -> $f"
        ;;
      show)
        tpl_name_ok "$name" || die "usage: secrets template show <name>"
        f=$(tpl_path "$name"); [ -f "$f" ] || die "no such template: $name"
        ${PAGER:-less -R} "$f"
        ;;
      *) die "usage: secrets template <new|edit|copy|rm|show|import> <name>" ;;
    esac
    ;;

  rm)
    # Remove a whole store. Mirrored copies are left alone on purpose -- they
    # are the backup; use 'purge' if you want those gone too.
    name="${1:-}"
    [ -n "$name" ] || die "usage: secrets rm <store>"
    S=$(store_path "$name")
    [ -f "$S" ] || die "$S not found"
    echo "This removes the store $S."
    echo "Mirrored copies in your mirrors are NOT touched ('secrets mirrors $name' lists them)."
    printf 'Type YES to proceed: '; read -r ans
    [ "$ans" = YES ] || die "aborted — nothing removed"
    shred -u "$S" 2>/dev/null || rm -f "$S"
    rm -f "$S.prev" "$(genfile "$name")"
    echo "removed $name"
    ;;

  archive)
    # Encrypt a whole file or directory. For material too big for a password
    # manager note -- e.g. server_mirror/cockpit_private/, which is 5.6MB and,
    # since it was untracked from git, is no longer in the backup repo.
    SRC="${1:-}"; [ -n "$SRC" ] || die "usage: secrets archive <path> [dest-dir]"
    [ -e "$SRC" ] || die "no such path: $SRC"
    DEST="${2:-$ARCHIVES}"
    mkdir -p "$DEST"; chmod 700 "$DEST"
    base=$(basename "$(readlink -f "$SRC")")
    out="$DEST/${base}_$(date +%Y-%m-%d).tar.gz.gpg"
    [ -e "$out" ] && die "$out already exists — remove it or pick another dest"
    mkwork
    tar czf "$WORK/a.tar.gz" -C "$(dirname "$(readlink -f "$SRC")")" "$base" \
        || die "tar failed — nothing written"
    gpg "${GPGOPTS[@]}" -o "$WORK/a.gpg" "$WORK/a.tar.gz" || die "encryption failed"
    gpg --quiet --decrypt "$WORK/a.gpg" >/dev/null 2>&1 || die "archive did not verify — nothing written"
    mv -f "$WORK/a.gpg" "$out"; chmod 600 "$out"
    echo "wrote $out  ($(stat -c%s "$out") bytes)"
    echo "Copy it somewhere OFF this machine — that is the whole point."
    ;;

  restore)
    SRC="${1:-}"; OUTDIR="${2:-}"
    [ -n "$SRC" ] && [ -n "$OUTDIR" ] || die "usage: secrets restore <file.tar.gz.gpg> <target-dir>"
    [ -f "$SRC" ] || die "no such archive: $SRC"
    mkdir -p "$OUTDIR"
    mkwork
    gpg --quiet --decrypt -o "$WORK/a.tar.gz" "$SRC" || die "decrypt failed"
    tar xzf "$WORK/a.tar.gz" -C "$OUTDIR" || die "extract failed"
    echo "restored into $OUTDIR"
    ;;

  -h|--help|help)
    sed -n '3,40p' "$0" | sed 's|^# \{0,1\}||'
    exit 0 ;;

  -V|--version|version)
    echo "secrets $SECRETS_VERSION"
    exit 0 ;;

  *)
    [ -n "$cmd" ] && echo "secrets: unknown command: $cmd" >&2
    sed -n '3,40p' "$0" | sed 's|^# \{0,1\}||'
    exit 1 ;;
esac
