- 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>
187 lines
6.6 KiB
Bash
Executable File
187 lines
6.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# journal.sh — atomic backup-and-write journal for Larry-Anywhere v3.
|
|
#
|
|
# Every modification to a file goes through this journal:
|
|
# 1. Snapshot the original (if it exists) to a session-scoped backup dir.
|
|
# 2. Compute a unified diff between original and new content.
|
|
# 3. Append a record to $LARRY_HOME/journal/index.tsv.
|
|
# 4. Append a human-readable entry to $LARRY_HOME/journal/<session>/manifest.md.
|
|
# 5. Atomically write the new content to the target.
|
|
#
|
|
# Rollback support: `larry-rollback.sh` (sibling script) can restore any subset.
|
|
#
|
|
# This script is SOURCEABLE — Larry-Anywhere's tool_write_file sources it and
|
|
# calls `journal_write`. It can also be invoked directly as a CLI.
|
|
#
|
|
# CLI:
|
|
# journal.sh write <target> <content_file> # snapshot, diff, write
|
|
# journal.sh list [--session S] # list entries
|
|
# journal.sh show <entry_id> # print diff for one entry
|
|
# journal.sh session-manifest [<session>] # print this/given session's manifest
|
|
#
|
|
# Env:
|
|
# LARRY_HOME (required) where the journal lives
|
|
# LARRY_SESSION_ID (optional) defaults to a sortable timestamp
|
|
set -u
|
|
set -o pipefail
|
|
|
|
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
|
LARRY_SESSION_ID="${LARRY_SESSION_ID:-$(date +%Y-%m-%d-%H%M%S)-$$}"
|
|
|
|
JOURNAL_ROOT="$LARRY_HOME/journal"
|
|
JOURNAL_INDEX="$JOURNAL_ROOT/index.tsv"
|
|
SESSION_DIR="$JOURNAL_ROOT/$LARRY_SESSION_ID"
|
|
SESSION_FILES="$SESSION_DIR/files"
|
|
SESSION_MANIFEST="$SESSION_DIR/manifest.md"
|
|
|
|
_journal_init() {
|
|
mkdir -p "$SESSION_FILES" 2>/dev/null || return 1
|
|
if [ ! -f "$JOURNAL_INDEX" ]; then
|
|
printf 'timestamp\tsession\tentry_id\ttarget\taction\torig_sha256\tnew_sha256\tbackup_path\tdiff_path\n' > "$JOURNAL_INDEX"
|
|
fi
|
|
if [ ! -f "$SESSION_MANIFEST" ]; then
|
|
{
|
|
echo "# Larry-Anywhere journal — session $LARRY_SESSION_ID"
|
|
echo "- started: $(date -Iseconds 2>/dev/null || date)"
|
|
echo "- host: $(hostname 2>/dev/null || echo unknown)"
|
|
echo "- larry-version: $(cat "$LARRY_HOME/VERSION" 2>/dev/null || echo unknown)"
|
|
echo ""
|
|
echo "Every write below has a backup at \`files/NNN_<basename>.orig\` and a diff at \`files/NNN_<basename>.diff\`."
|
|
echo "Roll back with: \`larry-rollback.sh --session $LARRY_SESSION_ID\` (or per-entry)."
|
|
echo ""
|
|
} > "$SESSION_MANIFEST"
|
|
fi
|
|
}
|
|
|
|
_sha() {
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
sha256sum "$1" | awk '{print $1}'
|
|
elif command -v shasum >/dev/null 2>&1; then
|
|
shasum -a 256 "$1" | awk '{print $1}'
|
|
else
|
|
cksum "$1" | awk '{print $1"-"$2}'
|
|
fi
|
|
}
|
|
|
|
_next_seq() {
|
|
# Number of *.orig|*.new files in session_files / 2, rounded up.
|
|
# v0.7.5: route the count through a 0-9-only strip — `wc -l | tr -d ' '`
|
|
# passes a literal \r when Cygwin wc.exe is in PATH, which then crashes
|
|
# `$(( (n / 2) + 1 ))` with "invalid arithmetic operator".
|
|
local raw n
|
|
raw=$(find "$SESSION_FILES" -maxdepth 1 -name '*.orig' -o -name '*.new' 2>/dev/null | wc -l)
|
|
n=$(printf '%s' "$raw" | tr -cd '0-9')
|
|
: "${n:=0}"
|
|
printf '%03d' $(( (n / 2) + 1 ))
|
|
}
|
|
|
|
# Main entry. $1=target file path, $2=path to file containing new content.
|
|
# Returns the entry_id on stdout. Prints user-facing diff on stderr.
|
|
journal_write() {
|
|
local target="$1"
|
|
local newfile="$2"
|
|
[ -n "$target" ] || { echo "journal_write: missing target" >&2; return 2; }
|
|
[ -f "$newfile" ] || { echo "journal_write: new-content file not found: $newfile" >&2; return 2; }
|
|
|
|
_journal_init
|
|
|
|
local seq base entry_id
|
|
seq=$(_next_seq)
|
|
base=$(basename "$target")
|
|
entry_id="${LARRY_SESSION_ID}/${seq}_${base}"
|
|
|
|
local backup="$SESSION_FILES/${seq}_${base}.orig"
|
|
local newcopy="$SESSION_FILES/${seq}_${base}.new"
|
|
local diffp="$SESSION_FILES/${seq}_${base}.diff"
|
|
local action="create"
|
|
local orig_sha=""
|
|
|
|
if [ -e "$target" ]; then
|
|
cp -p "$target" "$backup" || return 3
|
|
orig_sha=$(_sha "$backup")
|
|
action="modify"
|
|
else
|
|
: > "$backup" # placeholder for rollback (empty file)
|
|
orig_sha=""
|
|
fi
|
|
|
|
cp -p "$newfile" "$newcopy" || return 3
|
|
local new_sha; new_sha=$(_sha "$newcopy")
|
|
|
|
diff -u "$backup" "$newcopy" > "$diffp" 2>/dev/null || true
|
|
|
|
# Atomically write the new content
|
|
local target_dir; target_dir=$(dirname "$target")
|
|
mkdir -p "$target_dir" 2>/dev/null
|
|
cp -p "$newfile" "$target.larry-tmp.$$" && mv -f "$target.larry-tmp.$$" "$target"
|
|
|
|
# Append index
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"$(date -Iseconds 2>/dev/null || date)" \
|
|
"$LARRY_SESSION_ID" "$seq" "$target" "$action" \
|
|
"$orig_sha" "$new_sha" "$backup" "$diffp" >> "$JOURNAL_INDEX"
|
|
|
|
# Append session manifest
|
|
{
|
|
printf '\n## %s %s `%s`\n' "$seq" "$action" "$target"
|
|
printf -- '- orig sha256: %s\n' "${orig_sha:-(none — new file)}"
|
|
printf -- '- new sha256: %s\n' "$new_sha"
|
|
printf -- '- backup: `files/%s_%s.orig`\n' "$seq" "$base"
|
|
printf -- '- diff: `files/%s_%s.diff`\n' "$seq" "$base"
|
|
if [ "$action" = "modify" ]; then
|
|
printf -- '- change summary: %s lines added, %s lines removed\n' \
|
|
"$(grep -c '^+[^+]' "$diffp" 2>/dev/null || echo 0)" \
|
|
"$(grep -c '^-[^-]' "$diffp" 2>/dev/null || echo 0)"
|
|
fi
|
|
} >> "$SESSION_MANIFEST"
|
|
|
|
# User-facing: print the entry id (small) on stdout
|
|
printf '%s\n' "$entry_id"
|
|
}
|
|
|
|
journal_list() {
|
|
local filter_session=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in --session) shift; filter_session="$1" ;; esac
|
|
shift
|
|
done
|
|
[ -f "$JOURNAL_INDEX" ] || { echo "(no journal yet)"; return 0; }
|
|
if [ -n "$filter_session" ]; then
|
|
awk -F'\t' -v s="$filter_session" 'NR==1 || $2==s' "$JOURNAL_INDEX"
|
|
else
|
|
cat "$JOURNAL_INDEX"
|
|
fi
|
|
}
|
|
|
|
journal_show() {
|
|
local entry_id="$1"
|
|
local session seq
|
|
session="${entry_id%/*}"
|
|
seq="${entry_id##*/}"
|
|
seq="${seq%%_*}"
|
|
local f
|
|
f=$(find "$JOURNAL_ROOT/$session/files" -maxdepth 1 -name "${seq}_*.diff" 2>/dev/null | head -1)
|
|
[ -n "$f" ] || { echo "no such entry: $entry_id" >&2; return 2; }
|
|
cat "$f"
|
|
}
|
|
|
|
journal_session_manifest() {
|
|
local session="${1:-$LARRY_SESSION_ID}"
|
|
local f="$JOURNAL_ROOT/$session/manifest.md"
|
|
[ -f "$f" ] || { echo "no manifest for session $session" >&2; return 2; }
|
|
cat "$f"
|
|
}
|
|
|
|
# CLI dispatch (only when invoked directly, not sourced)
|
|
if [ "${BASH_SOURCE[0]:-$0}" = "${0}" ]; then
|
|
cmd="${1:-help}"
|
|
case "$cmd" in
|
|
write) [ $# -ge 3 ] || { echo "usage: $0 write <target> <new-content-file>" >&2; exit 2; }; journal_write "$2" "$3" ;;
|
|
list) shift; journal_list "$@" ;;
|
|
show) [ $# -ge 2 ] || { echo "usage: $0 show <entry_id>" >&2; exit 2; }; journal_show "$2" ;;
|
|
session-manifest) journal_session_manifest "${2:-}" ;;
|
|
help|-h|--help) sed -n '2,25p' "$0" ;;
|
|
*) echo "unknown subcommand: $cmd" >&2; exit 2 ;;
|
|
esac
|
|
fi
|