#!/usr/bin/env bash # # ============================================================================ # backuptousb - tar folders straight onto the external disk # ============================================================================ # # TLDR # ---- # cd /home/youruser # backuptousb claude/ # Enter accepts /media/youruser/usbdiskname/backups/claude.tar.zst # backuptousb claude/ web/ ssh/ # one archive PER folder, no prompts between # backuptousb .claude.json # a plain file works too -> .claude.json.tar.zst # backuptousb claude/ notes.txt bin/ # mix folders and files freely # backuptousb claude/ -y # no prompts # backuptousb -h # this header # # extracttodisk claude.tar.zst # unpack it again (companion script) # # INSTALL # chmod +x ~/scripts/backuptousb # echo "alias backuptousb='~/scripts/backuptousb'" >> ~/.bashrc # source ~/.bashrc # # RAW TAR (what this wraps, if you'd rather type it yourself) # pack: tar -I 'zstd -3 -T0' -cf /media/youruser/usbdiskname/backups/claude.tar.zst claude/ # unpack: tar -I zstd -xf claude.tar.zst # list: tar -I zstd -tf claude.tar.zst | less # gzip: tar -czf out.tar.gz claude/ | tar -xzf out.tar.gz # none: tar -cf out.tar claude/ | tar -xf out.tar # # ---------------------------------------------------------------------- # # Why bother: 50,000 small files = 50,000 open/write/close round-trips, and # you crawl at 2 MB/s on a drive that can do 100+. One archive is a single # sequential stream = full disk speed. # # MANY SOURCES: each gets its OWN archive (claude.tar.zst, notes.txt.tar.zst, # ...), so you can restore one without touching the others, and re-run just # the one that changed. Folders and plain files may be mixed in one command. # You're asked for the destination FOLDER once, up front, then it works # through the list unattended. One source that fails doesn't stop the rest - # failures are listed at the end. # # Run from the PARENT of what you're archiving. Relative paths only (it # refuses absolute ones), so paths inside the archive are `claude/...` and # extracting from /home/youruser recreates /home/youruser/claude/. # # Skips: node_modules, .git, cache, .cache, db-data, mysql-data (edit # EXCLUDES below). Unreadable files are skipped, never fatal - one root-owned # file used to abort a 60 GB run at 55 GB. Skips are counted on screen and # listed in .skipped.log next to the archive. # # Both scripts are copied next to the archives on every run, so the backup # disk always carries the tools to restore itself. # # SAFE OVERWRITE: if apps.tar.zst already exists, an interactive run asks # before replacing it. Either way, tar writes to apps.tar.zst.partial and is # renamed into place only on success - so a run that dies partway (or a -y run # with no prompt) NEVER destroys the existing good archive. There is no # incremental/diff logic: each run re-reads the whole source. For true "only # what changed", use rsync alongside this, not tar. # # Prints a live progress bar with an ETA per folder, then a summary: source # size and file count, archive size, compression ratio, elapsed, throughput. # # Caveat: writing straight to USB means a mid-write disconnect leaves a # truncated archive and no local copy. For anything critical, pack to local # disk first, then rsync --progress the single file over. # # ============================================================================ # EDIT ME - defaults # ============================================================================ DEFAULT_DEST="/media/youruser/usbdiskname/backups" # where archives land COMPRESSION="zstd" # zstd | gzip | none ZSTD_LEVEL="3" # 1-19; 3 is fast+good DATESTAMP="no" # yes -> claude-20260716.tar.zst EXCLUDES=( # skip these everywhere '*/node_modules' '*/.git' '*/cache/*' '*/.cache/*' '*/db-data' # live MariaDB/MySQL container data - see note below '*/mysql-data' ) # NOTE on db-data: those dirs are owned by the container's mysql uid (999), # so tar can't read them as you anyway. More importantly a live InnoDB dir # copied out from under a running server is a TORN snapshot - it may simply # not restore. Back those up properly instead: # docker exec mariadb-dump -uroot -p --all-databases \ # > /home/youruser/claude/projects//dump-$(date +%Y%m%d).sql # The .sql dump lives inside the project folder, so this script picks it up. # ============================================================================ # script # ============================================================================ set -euo pipefail die() { printf 'backuptousb: %s\n' "$1" >&2; exit 1; } ASSUME_YES="no" OUT_OVERRIDE="" SRCS=() while [ $# -gt 0 ]; do case "$1" in -y|--yes) ASSUME_YES="yes"; shift ;; -o|--out) OUT_OVERRIDE="${2:-}"; [ -n "$OUT_OVERRIDE" ] || die "-o needs a path"; shift 2 ;; # Print the leading comment block: skip the shebang, take every line that # still starts with '#', stop at the first that doesn't. Grows with the # header automatically - no line numbers to keep in sync. -h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"; exit 0 ;; -*) die "unknown option: $1" ;; *) SRCS+=("$1"); shift ;; esac done [ "${#SRCS[@]}" -gt 0 ] || die "no source folder given. try: backuptousb claude/" # -o names one exact archive path, so it can't apply to a list. if [ -n "$OUT_OVERRIDE" ] && [ "${#SRCS[@]}" -gt 1 ]; then die "-o names a single archive, but ${#SRCS[@]} sources were given. drop -o (each source gets its own archive), or back them up one at a time." fi # --- validate every source BEFORE packing anything ------------------------ # Fail fast on a typo in source 3 rather than 40 minutes into source 1. # Folders AND plain files are allowed: a folder becomes folder.tar.zst, a file # becomes filename.txt.tar.zst, and both restore back to their original name. CLEAN_SRCS=() for s in "${SRCS[@]}"; do s="${s%/}" case "$s" in /*) die "pass a RELATIVE path (cd to the parent first): $s absolute paths bake /home/... into the archive." ;; ..*) die "refusing '..' paths - cd to the parent of what you want: $s" ;; esac [ -e "$s" ] || die "no such file or directory: $s" CLEAN_SRCS+=("$s") done case "$COMPRESSION" in zstd) EXT=".tar.zst"; TAR_COMP=(-I "zstd -${ZSTD_LEVEL} -T0") ;; gzip) EXT=".tar.gz"; TAR_COMP=(-z) ;; none) EXT=".tar"; TAR_COMP=() ;; *) die "COMPRESSION must be zstd, gzip, or none (got: $COMPRESSION)" ;; esac if [ "$COMPRESSION" = "zstd" ] && ! command -v zstd >/dev/null 2>&1; then die "zstd not installed. set COMPRESSION=\"gzip\" near the top of this script." fi # --- ask for the destination ONCE ----------------------------------------- # With several folders we ask for the FOLDER they land in, not a filename - # each archive is named after its source dir. if [ -n "$OUT_OVERRIDE" ]; then OUT_DIR="$(dirname "$OUT_OVERRIDE")" printf 'Destination: %s\n' "$OUT_OVERRIDE" elif [ "$ASSUME_YES" = "yes" ] || [ ! -t 0 ]; then OUT_DIR="$DEFAULT_DEST" printf 'Destination: %s/\n' "$OUT_DIR" elif [ "${#CLEAN_SRCS[@]}" -eq 1 ]; then # Single folder: keep the old behaviour - offer the full editable filename. SINGLE_BASE="$(basename "${CLEAN_SRCS[0]}")" [ "$DATESTAMP" = "yes" ] && SINGLE_BASE="${SINGLE_BASE}-$(date +%Y%m%d)" read -e -i "$DEFAULT_DEST/$SINGLE_BASE$EXT" -r -p "Destination (Enter to accept): " reply OUT_OVERRIDE="${reply:-$DEFAULT_DEST/$SINGLE_BASE$EXT}" OUT_DIR="$(dirname "$OUT_OVERRIDE")" else printf '\n%s sources to back up:\n' "${#CLEAN_SRCS[@]}" for s in "${CLEAN_SRCS[@]}"; do # trailing slash only for real directories, so files show as "notes.txt" if [ -d "$s" ]; then printf ' %s/ -> %s%s\n' "$s" "$(basename "$s")" "$EXT" else printf ' %s -> %s%s\n' "$s" "$(basename "$s")" "$EXT"; fi done printf '\n' read -e -i "$DEFAULT_DEST" -r -p "Destination folder (Enter to accept): " reply OUT_DIR="${reply:-$DEFAULT_DEST}" OUT_DIR="${OUT_DIR%/}" fi [ -d "$OUT_DIR" ] || die "destination folder does not exist: $OUT_DIR is the disk mounted? check with: df -h | grep media" [ -w "$OUT_DIR" ] || die "destination folder not writable: $OUT_DIR" EX_ARGS=() for pat in "${EXCLUDES[@]}"; do EX_ARGS+=(--exclude="$pat"); done SLOTS=28 RUN_START="$(date +%s)" SKIPLOG="$(mktemp /tmp/backuptousb-skipped.XXXXXX.log)" # CURRENT_PARTIAL holds the in-progress .partial so a Ctrl-C / kill mid-tar # doesn't leave a half-written archive littering the backup disk. Cleared # after each folder finalises. INT/TERM re-raise so the exit code is honest. CURRENT_PARTIAL="" cleanup() { rm -f "$SKIPLOG"; [ -n "$CURRENT_PARTIAL" ] && rm -f "$CURRENT_PARTIAL"; } trap cleanup EXIT trap 'cleanup; exit 130' INT TERM # Run-level tallies for the closing summary. DONE_COUNT=0 FAIL_LIST=() TOTAL_SRC_BYTES=0 TOTAL_OUT_BYTES=0 # ========================================================================== # pack one folder - returns non-zero on failure, never exits the script # ========================================================================== pack_one() { local SRC="$1" idx="$2" total="$3" local BASE OUT TOTAL_FILES TOTAL_KB_RAW TOTAL_H local START_EPOCH TAR_RC ELAPSED SKIPPED local OUT_BYTES OUT_H SRC_BYTES BASE="$(basename "$SRC")" [ "$DATESTAMP" = "yes" ] && BASE="${BASE}-$(date +%Y%m%d)" OUT="${OUT_OVERRIDE:-$OUT_DIR/$BASE$EXT}" # Trailing slash only for directories - "file.txt/" would be wrong/confusing. local SLASH="/"; [ -d "$SRC" ] || SLASH="" if [ "$total" -gt 1 ]; then printf '\n\033[1m[%s/%s] %s%s\033[0m\n' "$idx" "$total" "$SRC" "$SLASH" fi printf '\nPacking %s%s\n -> %s\n\n' "$SRC" "$SLASH" "$OUT" # --- measure so the bar means something --------------------------------- # Count FILES, not bytes: byte-based progress is wildly inaccurate on trees # of many small files (each rounds up to a 512 B tar record - measured, a # 20k tiny-file tree jumped 38% -> 100%). tar -v emits one line per member. # Done BEFORE the overwrite prompt so that prompt can show the source size # next to the existing archive's - you decide with real numbers, not blind. printf 'Sizing... ' local FIND_PRUNE=( -name node_modules -o -name .git -o -name db-data -o -name mysql-data -o -name cache -o -name .cache ) # `|| true`: find exits non-zero on unreadable dirs; under set -e that would # abort before tar even starts. Unreadable paths are expected, never fatal. TOTAL_FILES="$(find "$SRC" \( "${FIND_PRUNE[@]}" \) -prune -o -print 2>/dev/null | wc -l || true)" TOTAL_FILES="${TOTAL_FILES:-0}" [ "$TOTAL_FILES" -lt 1 ] && TOTAL_FILES=1 TOTAL_KB_RAW="$(du -sk --exclude=node_modules --exclude=.git --exclude=db-data \ --exclude=mysql-data --exclude=cache --exclude=.cache \ "$SRC" 2>/dev/null | cut -f1 || true)" TOTAL_KB_RAW="${TOTAL_KB_RAW:-0}" TOTAL_H="$(numfmt --to=iec --from-unit=1024 "$TOTAL_KB_RAW" 2>/dev/null || echo "${TOTAL_KB_RAW}K")" printf '%s files, %s\n' "$TOTAL_FILES" "$TOTAL_H" # --- overwrite prompt, now with numbers to judge by --------------------- if [ -e "$OUT" ] && [ "$ASSUME_YES" != "yes" ] && [ -t 0 ]; then local reply old_bytes old_h old_age old_bytes="$(stat -c %s "$OUT" 2>/dev/null || echo 0)" old_h="$(numfmt --to=iec "$old_bytes" 2>/dev/null || echo '?')" # mtime as "YYYY-MM-DD HH:MM" plus a rough age in days. old_age="$(stat -c %y "$OUT" 2>/dev/null | cut -d. -f1 | cut -c1-16 || echo '?')" printf '\n%s already exists:\n' "$(basename "$OUT")" printf ' existing archive %8s packed %s\n' "$old_h" "$old_age" printf ' %s source now %8s (%s files, uncompressed)\n' "$SRC$SLASH" "$TOTAL_H" "$TOTAL_FILES" read -r -p "$(printf '\nOverwrite? [y/N] ')" reply case "$reply" in [yY]*) ;; *) printf 'Skipped %s\n' "$SRC"; return 0 ;; esac fi printf '\n' START_EPOCH="$(date +%s)" : > "$SKIPLOG" # Safe overwrite: tar writes to a .partial beside the target, and we rename # to $OUT only after a clean exit. So an existing good archive is never # destroyed by a run that dies partway - including a -y run with no prompt. # (tar writes straight to its -f target, so without this a killed run leaves # a truncated file under the real name.) local WORK="$OUT.partial" rm -f "$WORK" CURRENT_PARTIAL="$WORK" # so the INT/TERM trap can wipe it if we're killed # --- the actual tar ----------------------------------------------------- # --ignore-failed-read: unreadable files become warnings instead of killing # the run. Without it one root-owned file aborts a 60 GB backup at 55 GB. # The excludes stop us WALKING known-bad dirs; this catches the unpredicted. set +e +o pipefail tar "${TAR_COMP[@]}" -cvf "$WORK" "${EX_ARGS[@]}" \ --ignore-failed-read \ --warning=no-file-changed --warning=no-file-removed \ "$SRC" 2>>"$SKIPLOG" | awk -v total="$TOTAL_FILES" -v slots="$SLOTS" -v start="$START_EPOCH" ' { n++ pct = int(n * 100 / total); if (pct > 100) pct = 100 now = systime(); elapsed = now - start if (pct != last_pct || now != last_now) { filled = int(pct * slots / 100) bar = "" for (i = 0; i < slots; i++) bar = bar (i < filled ? "[#]" : "[ ]") if (pct > 2 && elapsed > 2) { eta = int(elapsed * (100 - pct) / pct) printf "\r%s %3d%% %02d:%02d left (%d/%d) ", bar, pct, eta/60, eta%60, n, total > "/dev/stderr" } else { printf "\r%s %3d%% --:-- left (%d/%d) ", bar, pct, n, total > "/dev/stderr" } last_pct = pct; last_now = now } } ' TAR_RC=${PIPESTATUS[0]} set -e -o pipefail # `local b`: without it this loop's counter leaks out and clobbers the main # loop's index - printed "[29/3]" instead of "[2/3]". local FINAL_BAR="" b for (( b = 0; b < SLOTS; b++ )); do FINAL_BAR+="[#]"; done ELAPSED=$(( $(date +%s) - START_EPOCH )) printf '\r%s 100%% %02d:%02d taken \n' "$FINAL_BAR" $(( ELAPSED / 60 )) $(( ELAPSED % 60 )) # With --ignore-failed-read, unreadable files no longer set a failure code, # so anything non-zero here is REAL (disk full, bad target, killed). if [ "$TAR_RC" -ne 0 ]; then printf '\n' [ -s "$SKIPLOG" ] && tail -3 "$SKIPLOG" rm -f "$WORK"; CURRENT_PARTIAL="" # ditch partial; leave existing $OUT intact printf 'FAILED: %s (tar exit %s) - partial discarded' "$SRC" "$TAR_RC" [ -e "$OUT" ] && printf ', existing %s kept' "$(basename "$OUT")" printf '.\n' return 1 fi # Clean run: promote the .partial to the real name (atomic on same fs). mv -f "$WORK" "$OUT" || { printf 'FAILED: could not finalise %s\n' "$OUT"; return 1; } CURRENT_PARTIAL="" # finalised - nothing for the trap to clean SKIPPED="$(grep -c 'Cannot open\|Cannot read\|Permission denied' "$SKIPLOG" 2>/dev/null || true)" SKIPPED="${SKIPPED:-0}" if [ "$SKIPPED" -gt 0 ]; then printf '\nSkipped %s unreadable file(s) - root/container-owned, not yours to read.\n' "$SKIPPED" printf 'Backup otherwise complete. Full list:\n %s\n' "$OUT_DIR/$(basename "$OUT").skipped.log" cp -p "$SKIPLOG" "$OUT_DIR/$(basename "$OUT").skipped.log" 2>/dev/null || true fi printf '\nFlushing to disk... ' # sync can sit for a while on USB sync printf 'done\n' # --- per-folder summary ------------------------------------------------- OUT_BYTES="$(stat -c %s "$OUT" 2>/dev/null || echo 0)" OUT_H="$(numfmt --to=iec "$OUT_BYTES" 2>/dev/null || echo "${OUT_BYTES}B")" SRC_BYTES=$(( TOTAL_KB_RAW * 1024 )) printf '\n' printf ' Source %s (%s files)\n' "$TOTAL_H" "$TOTAL_FILES" printf ' Archive %s -> %s\n' "$OUT_H" "$OUT" if [ "$SRC_BYTES" -gt 0 ] && [ "$OUT_BYTES" -gt 0 ]; then # One decimal: integer % shows a bare "0%" for anything under 1:100, # which looks like a bug rather than very good compression. printf ' Shrunk to %s.%s%% of original\n' \ "$(( OUT_BYTES * 1000 / SRC_BYTES / 10 ))" "$(( OUT_BYTES * 1000 / SRC_BYTES % 10 ))" fi printf ' Time %dm %ds\n' $(( ELAPSED / 60 )) $(( ELAPSED % 60 )) if [ "$ELAPSED" -gt 0 ] && [ "$SRC_BYTES" -gt 0 ]; then printf ' Speed %s/s read\n' \ "$(numfmt --to=iec $(( SRC_BYTES / ELAPSED )) 2>/dev/null || echo '?')" else printf ' Speed (too fast to measure)\n' fi TOTAL_SRC_BYTES=$(( TOTAL_SRC_BYTES + SRC_BYTES )) TOTAL_OUT_BYTES=$(( TOTAL_OUT_BYTES + OUT_BYTES )) return 0 } # ========================================================================== # main loop - one folder failing must not stop the rest # ========================================================================== N="${#CLEAN_SRCS[@]}" i=0 for SRC in "${CLEAN_SRCS[@]}"; do i=$(( i + 1 )) if pack_one "$SRC" "$i" "$N"; then DONE_COUNT=$(( DONE_COUNT + 1 )) else FAIL_LIST+=("$SRC") fi done # --- run-level summary (only worth printing for a list) ------------------- if [ "$N" -gt 1 ]; then RUN_ELAPSED=$(( $(date +%s) - RUN_START )) printf '\n========================================\n' printf ' %s of %s sources packed\n' "$DONE_COUNT" "$N" printf ' %s -> %s in ' \ "$(numfmt --to=iec "$TOTAL_SRC_BYTES" 2>/dev/null || echo '?')" \ "$(numfmt --to=iec "$TOTAL_OUT_BYTES" 2>/dev/null || echo '?')" if [ "$RUN_ELAPSED" -ge 3600 ]; then printf '%dh %dm %ds\n' $(( RUN_ELAPSED / 3600 )) $(( RUN_ELAPSED % 3600 / 60 )) $(( RUN_ELAPSED % 60 )) else printf '%dm %ds\n' $(( RUN_ELAPSED / 60 )) $(( RUN_ELAPSED % 60 )) fi if [ "${#FAIL_LIST[@]}" -gt 0 ]; then printf '\n FAILED (%s):\n' "${#FAIL_LIST[@]}" for f in "${FAIL_LIST[@]}"; do printf ' %s\n' "$f"; done fi printf '========================================\n' fi # --- drop both scripts next to the archives ------------------------------- # Overwrite when they differ (NOT copy-only-if-absent): the disk copy must # match the script that made the archive, or you're restoring with a stale # tool. Identical copies are left alone so mtimes stay meaningful. SELF="$(readlink -f "$0")" SELF_DIR="$(dirname "$SELF")" sync_script() { # $1 = source path, $2 = destination path local src="$1" dst="$2" [ -e "$src" ] || return 0 if [ ! -e "$dst" ]; then cp -p "$src" "$dst" && chmod +x "$dst" && printf ' + %s\n' "$dst" elif ! cmp -s "$src" "$dst"; then cp -p "$src" "$dst" && chmod +x "$dst" && printf ' ~ %s (updated)\n' "$dst" fi } printf '\nScripts on the backup disk:\n' sync_script "$SELF" "$OUT_DIR/backuptousb" sync_script "$SELF_DIR/extracttodisk" "$OUT_DIR/extracttodisk" printf '\nTo restore: %s/extracttodisk \n' "$OUT_DIR" "$EXT" # Non-zero exit if anything failed, so `backuptousb a/ b/ && echo ok` behaves. [ "${#FAIL_LIST[@]}" -eq 0 ] || exit 1