cloverleaf-larry/larry-rollback.sh
Bryan Johnson 9dd5821436 v0.7.5: OAuth CR-taint fix + mouse opt-in + CR-safety sweep
- Fix bash arithmetic crash on MobaXterm/Cygwin: $(date +%s) was
  returning CR-tainted values landing in $(( )) operands
- Mouse mode off by default; opt in via LARRY_MOUSE=1 or /mouse on
- Comprehensive CR-safety sweep across lib/*.sh and larry.sh — every
  command-substitution result, file read, and user input that feeds
  an arithmetic context, case dispatcher, or path/header is now
  CR-stripped at the source

New shared helper lib/cygwin-safe.sh defines three primitives:
  coerce_int VAL [DEFAULT]   — for arithmetic / integer-test operands
  strip_cr VAL               — for case patterns, regex tests, paths, headers
  read_clean VAR [PROMPT]    — read -r wrapper that strips CR pre-assign

Hardened call sites (14 files, 60+ patch points):
  - larry.sh:  status-line date/tput, 3 y/N approvals, auth menu, API key
  - lib/oauth.sh:  cmd_login + cmd_refresh date+%s captures
  - lib/nc-engine.sh:  5 y/N action prompts + find|wc arithmetic
  - lib/nc-msgs.sh:  parse_time_ms (4 date sites) + meta-TSV time + MSG_COUNT
  - lib/nc-regression.sh:  tr|wc count + hl7-diff ?-fallback arithmetic
  - lib/nc-smat-diff.sh:  A_COUNT/B_COUNT/DIFFS_TOTAL
  - lib/nc-insert-protocol.sh:  every awk-emitted line number → head/tail math
  - lib/journal.sh:  _next_seq wc -l arithmetic
  - lib/lessons.sh:  _next_id/_count + 2 y/N prompts
  - lib/hl7-sanitize.sh:  cmd_count + clear-table y/N
  - lib/ssh-helper.sh:  4 local+remote wc -c integer compares
  - lib/nc-find.sh, lib/nc-table.sh, lib/nc-document.sh, larry-rollback.sh

Reproduces the exact error Bryan hit:
  bash: ...: arithmetic syntax error: invalid arithmetic operator (error token is "")

lib/cygwin-safe.sh added to MANIFEST so it auto-syncs on next launch.

Co-Authored-By: Clover (Claude Opus 4.7) <noreply@anthropic.com>
2026-05-27 19:17:48 -07:00

132 lines
4.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# larry-rollback.sh — restore files modified by Larry-Anywhere from journal backups.
#
# Usage:
# larry-rollback.sh --list # show every journal entry, newest first
# larry-rollback.sh --list --session SESSION # show entries for one session
# larry-rollback.sh --session SESSION # roll back ALL entries in a session (newest first)
# larry-rollback.sh --last N # roll back the N most recent entries
# larry-rollback.sh --entry ENTRY_ID # roll back one specific entry (e.g. 2026-05-26-.../004)
# larry-rollback.sh --target /path/to/file # roll back all entries that touched this file (newest first)
# larry-rollback.sh --dry-run # show what would be rolled back without doing it
#
# Y/N confirm on every restoration unless --yes is passed.
set -u
set -o pipefail
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
JOURNAL_ROOT="$LARRY_HOME/journal"
JOURNAL_INDEX="$JOURNAL_ROOT/index.tsv"
DRY=0
YES=0
MODE=""
SESSION=""
N=""
ENTRY_ID=""
TARGET=""
while [ $# -gt 0 ]; do
case "$1" in
--list) MODE="list" ;;
--session) shift; SESSION="$1"; [ -z "$MODE" ] && MODE="session" ;;
--last) shift; N="$1"; MODE="last" ;;
--entry) shift; ENTRY_ID="$1"; MODE="entry" ;;
--target) shift; TARGET="$1"; MODE="target" ;;
--dry-run) DRY=1 ;;
--yes|-y) YES=1 ;;
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
[ -f "$JOURNAL_INDEX" ] || { echo "no journal (no writes have been made yet)" >&2; exit 1; }
C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RESET=$'\033[0m'
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'
_list_filter() {
local ses="$1"
awk -F'\t' -v s="$ses" '
NR==1 { next }
s=="" || $2==s { print }
' "$JOURNAL_INDEX" | tail -r 2>/dev/null || awk -F'\t' -v s="$ses" '
NR==1 { next }
s=="" || $2==s { print }
' "$JOURNAL_INDEX" | awk '{a[NR]=$0} END{for(i=NR;i>=1;i--) print a[i]}'
}
if [ "$MODE" = "list" ]; then
printf '%stimestamp session-id seq target%s\n' "$C_BOLD" "$C_RESET"
_list_filter "$SESSION" | awk -F'\t' '{printf " %-26s %-28s %-4s %s\n", $1, $2, $3, $4}'
exit 0
fi
build_targets() {
case "$MODE" in
session)
[ -n "$SESSION" ] || { echo "--session needs a value" >&2; exit 2; }
_list_filter "$SESSION"
;;
last)
[ -n "$N" ] || { echo "--last needs N" >&2; exit 2; }
_list_filter "" | head -n "$N"
;;
entry)
local ses seq
ses="${ENTRY_ID%/*}"; seq="${ENTRY_ID##*/}"; seq="${seq%%_*}"
awk -F'\t' -v s="$ses" -v sq="$seq" '$2==s && $3==sq' "$JOURNAL_INDEX"
;;
target)
awk -F'\t' -v t="$TARGET" '$4==t' "$JOURNAL_INDEX" | awk '{a[NR]=$0} END{for(i=NR;i>=1;i--) print a[i]}'
;;
*)
echo "specify --list, --session, --last N, --entry ID, or --target PATH" >&2
exit 2
;;
esac
}
ENTRIES=$(build_targets)
[ -n "$ENTRIES" ] || { echo "(no matching journal entries)"; exit 0; }
printf '%sWill roll back %s entr(y/ies):%s\n' "$C_BOLD" "$(printf '%s\n' "$ENTRIES" | wc -l | tr -d ' ')" "$C_RESET"
printf '%s' "$ENTRIES" | awk -F'\t' '{printf " %s %-26s %s\n", $3, $1, $4}'
echo ""
if [ "$DRY" = "1" ]; then
echo "(dry-run; no changes)"
exit 0
fi
if [ "$YES" != "1" ]; then
printf '%sProceed?%s [y/N]: ' "$C_BOLD" "$C_RESET"
read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; exit 1; }
fi
printf '%s\n' "$ENTRIES" | while IFS=$'\t' read -r ts ses seq target action orig_sha new_sha backup diffp; do
[ -z "$target" ] && continue
if [ "$action" = "create" ]; then
if [ -e "$target" ]; then
cp -p "$target" "$target.larry-prerollback.$(date +%s)"
rm -f "$target" && printf ' %s✓ deleted%s %s (was newly created)\n' "$C_GREEN" "$C_RESET" "$target"
else
printf ' %s○ already absent%s %s\n' "$C_DIM" "$C_RESET" "$target"
fi
else
if [ -f "$backup" ]; then
cp -p "$target" "$target.larry-prerollback.$(date +%s)" 2>/dev/null || true
cp -p "$backup" "$target" && printf ' %s✓ restored%s %s ← %s\n' "$C_GREEN" "$C_RESET" "$target" "$backup"
else
printf ' %s✗ backup missing for%s %s (looked at %s)\n' "$C_RED" "$C_RESET" "$target" "$backup"
fi
fi
done
echo ""
echo "Pre-rollback copies left at <target>.larry-prerollback.<ts> in case you want to redo. Clean up at your leisure."