Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions scripts/hermes-preupdate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# hermes-preupdate: prepare for a `hermes update` run.
#
# What this does:
# 1. Snapshots the local Hermes source state (branch, commit, applied
# patches) to ~/.hermes/state-snapshots/<timestamp>-pre-update/.
# 2. Snapshots the saved local patches to ~/.hermes/state-snapshots/...
# /patches/ so they're not lost if the operator blows away
# ~/.hermes/patches/ by accident.
# 3. Verifies the post-merge hook is installed and executable.
# 4. Verifies the saved patch's target file still exists in the
# current source (i.e. the fix is still applied).
# 5. Verifies a recent `hermes update --backup` exists (within 14
# days) — if not, suggests running one before proceeding.
#
# This script is intentionally idempotent and read-only. It does NOT
# touch /usr/local/lib/hermes-agent, does NOT run git, does NOT modify
# any Hermes config. It only writes to ~/.hermes/state-snapshots/.
#
# Usage:
# hermes-preupdate
# hermes-preupdate --check # exit 1 if any check fails
# hermes-preupdate --quiet # no progress output, errors only

set -euo pipefail

QUIET=0
CHECK_MODE=0
for arg in "$@"; do
case "$arg" in
--check) CHECK_MODE=1 ;;
--quiet|-q) QUIET=1 ;;
-h|--help)
sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
exit 0
;;
esac
done

HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
REPO_ROOT="${HERMES_REPO_ROOT:-/usr/local/lib/hermes-agent}"
SNAPSHOT_BASE="$HERMES_HOME/state-snapshots"
STAMP="$(date -u +%Y%m%d-%H%M%S)"
SNAPSHOT_DIR="$SNAPSHOT_BASE/$STAMP-pre-update"
LOG="$HERMES_HOME/logs/preupdate.log"

mkdir -p "$(dirname "$LOG")"

say() {
if [[ $QUIET -eq 0 ]]; then
printf '%s\n' "$*"
fi
}

err() {
printf '✗ %s\n' "$*" >&2
printf '[%s] ERROR: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG"
}

log() {
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG"
}

EXIT=0

log "hermes-preupdate started (check=$CHECK_MODE quiet=$QUIET)"

# Check 1: HERMES_HOME exists
if [[ ! -d "$HERMES_HOME" ]]; then
err "$HERMES_HOME does not exist"
exit 1
fi

# Check 2: source checkout is present
if [[ ! -d "$REPO_ROOT/.git" ]]; then
err "$REPO_ROOT is not a git checkout — this script only handles the git-based install"
exit 1
fi

say "◆ Creating pre-update snapshot at $SNAPSHOT_DIR"
mkdir -p "$SNAPSHOT_DIR/patches"
log "snapshot dir: $SNAPSHOT_DIR"

# Capture git state
{
echo "# git state captured $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "branch: $(git -C "$REPO_ROOT" branch --show-current 2>&1)"
echo "commit: $(git -C "$REPO_ROOT" rev-parse HEAD 2>&1)"
echo "remote: $(git -C "$REPO_ROOT" remote get-url origin 2>&1)"
echo "status: "
git -C "$REPO_ROOT" status --short 2>&1 | sed 's/^/ /'
echo "applied-patches: $(git -C "$REPO_ROOT" log --oneline -5 2>&1)"
} > "$SNAPSHOT_DIR/git-state.txt"
say " git state → $SNAPSHOT_DIR/git-state.txt"

# Copy local patches
if [[ -d "$HERMES_HOME/patches" ]]; then
cp -a "$HERMES_HOME/patches/." "$SNAPSHOT_DIR/patches/"
say " patches → $SNAPSHOT_DIR/patches/ ($(ls "$SNAPSHOT_DIR/patches/" | wc -l) file(s))"
else
say " no patches/ directory to snapshot"
fi

# Check 3: post-merge hook is installed
HOOK_PATH="$REPO_ROOT/.git/hooks/post-merge"
if [[ -x "$HOOK_PATH" ]]; then
say " ✓ post-merge hook installed at $HOOK_PATH"
else
err "post-merge hook is missing or not executable at $HOOK_PATH"
err " re-install with: cp ~/.hermes/bin/hermes-post-merge-hook.sh $HOOK_PATH && chmod +x $HOOK_PATH"
EXIT=1
fi

# Check 4: saved patch's canary is present in current source
if [[ -d "$HERMES_HOME/patches" ]]; then
for patch in "$HERMES_HOME/patches/"*.patch; do
[[ -f "$patch" ]] || continue
# Try to detect the target file from the patch header
target=$(grep -m1 '^diff --git' "$patch" | awk '{print $NF}' | sed 's|^[ab]/||')
marker=$(grep -m1 '+++ ' "$patch" | head -1)
# The canary is the function name we added
if grep -q '_build_minimax_oauth_aux_client' "$patch" 2>/dev/null; then
if grep -qF '_build_minimax_oauth_aux_client' "$REPO_ROOT/$target" 2>/dev/null; then
say " ✓ patch $(basename "$patch") is currently applied (canary in $target)"
else
err "patch $(basename "$patch") is saved but the fix is MISSING from $target"
err " the post-merge hook will re-apply it, but the current state is broken"
EXIT=1
fi
fi
done
fi

# Check 5: recent backup exists (within 14 days)
if [[ -d "$HERMES_HOME/backups" ]]; then
LATEST_BACKUP=$(find "$HERMES_HOME/backups" -name 'pre-update-*.zip' -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | awk '{print $2}')
if [[ -n "$LATEST_BACKUP" ]]; then
BACKUP_AGE_DAYS=$(( ( $(date +%s) - $(stat -c %Y "$LATEST_BACKUP") ) / 86400 ))
if [[ $BACKUP_AGE_DAYS -le 14 ]]; then
say " ✓ latest backup is $BACKUP_AGE_DAYS day(s) old: $(basename "$LATEST_BACKUP")"
else
err "latest backup is $BACKUP_AGE_DAYS day(s) old — consider running 'hermes update --backup' first"
EXIT=1
fi
else
say " ⚠ no pre-update backup found in $HERMES_HOME/backups/"
say " consider running: hermes update --backup (or) hermes backup"
if [[ $CHECK_MODE -eq 1 ]]; then
EXIT=1
fi
fi
else
say " ⚠ no backups directory yet — first update will create one"
fi

say ""
say "Snapshot saved. To run the update safely:"
say " hermes update --backup"
say ""
say "To restore from this snapshot if something goes wrong:"
say " cp -a $SNAPSHOT_DIR/patches/. $HERMES_HOME/patches/"
say " cd $REPO_ROOT && git checkout \$(awk '/commit:/ {print \$2}' $SNAPSHOT_DIR/git-state.txt)"

log "hermes-preupdate finished (exit=$EXIT)"

if [[ $CHECK_MODE -eq 1 ]]; then
exit $EXIT
fi
149 changes: 149 additions & 0 deletions scripts/install-local-patch-hooks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# scripts/install-local-patch-hooks.sh
#
# Install the local-patch-recovery infrastructure into ~/.hermes/.
# Idempotent — safe to run multiple times. Read-only on the source
# checkout (only writes to ~/.hermes/ and the local .git/hooks/).
#
# What gets installed:
# ~/.hermes/patches/ — patch archive (created if missing)
# ~/.hermes/bin/hermes-post-merge-hook.sh — hook source (the template
# shipped in the repo is the
# same file, just kept in
# sync by re-running this)
# ~/.hermes/bin/hermes-preupdate.sh — pre-update preflight script
# ~/.hermes/patches/manifest.txt — manifest of tracked local
# patches (created if missing
# with a placeholder entry
# pointing at the well-known
# minimax-oauth fix)
# ~/.hermes/patches/README.md — documentation
# /usr/local/lib/hermes-agent/.git/hooks/post-merge — the actual hook
#
# Usage:
# ./scripts/install-local-patch-hooks.sh # install
# ./scripts/install-local-patch-hooks.sh --check # verify
# ./scripts/install-local-patch-hooks.sh --uninstall

set -euo pipefail

HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
REPO_ROOT="${HERMES_REPO_ROOT:-/usr/local/lib/hermes-agent}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

usage() {
sed -n '2,/^set -euo/p' "$0" | sed 's/^# \?//; s/^#//'
exit 0
}

CHECK_ONLY=0
UNINSTALL=0
for arg in "$@"; do
case "$arg" in
--check) CHECK_ONLY=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help) usage ;;
esac
done

PATCH_DIR="$HERMES_HOME/patches"
HOOK_DEST="$REPO_ROOT/.git/hooks/post-merge"
HOOK_SRC="$SCRIPT_DIR/local-patch-recovery-hook.sh"
PREUPDATE_SCRIPT="$SCRIPT_DIR/hermes-preupdate.sh"
README_SRC="$SCRIPT_DIR/local-patches-README.md"
MANIFEST="$PATCH_DIR/manifest.txt"

say() { printf '%s\n' "$*"; }
err() { printf '✗ %s\n' "$*" >&2; }
warn() { printf '⚠ %s\n' "$*"; }

if [[ $UNINSTALL -eq 1 ]]; then
say "Uninstalling local-patch-recovery infrastructure..."
if [[ -f "$HOOK_DEST" ]]; then
rm -f "$HOOK_DEST"
say " removed $HOOK_DEST"
fi
say " ~/.hermes/patches/ and ~/.hermes/bin/ left in place (manual cleanup if needed)"
say "Done."
exit 0
fi

# Pre-flight: source files exist
for f in "$HOOK_SRC" "$PREUPDATE_SCRIPT" "$README_SRC"; do
if [[ ! -f "$f" ]]; then
err "missing source file: $f"
exit 1
fi
done

if [[ $CHECK_ONLY -eq 1 ]]; then
say "Checking local-patch-recovery install..."
ERRORS=0
for f in "$HERMES_HOME/bin/hermes-post-merge-hook.sh" "$HERMES_HOME/bin/hermes-preupdate.sh" "$PATCH_DIR/README.md"; do
if [[ -f "$f" ]]; then
say " ✓ $f"
else
err "$f"
ERRORS=$((ERRORS+1))
fi
done
if [[ -x "$HOOK_DEST" ]]; then
say " ✓ $HOOK_DEST (executable)"
else
err "$HOOK_DEST (missing or not executable)"
ERRORS=$((ERRORS+1))
fi
if [[ -f "$MANIFEST" ]]; then
say " ✓ $MANIFEST"
else
err "$MANIFEST (missing)"
ERRORS=$((ERRORS+1))
fi
if [[ $ERRORS -eq 0 ]]; then
say "All checks passed."
exit 0
else
err "$ERRORS check(s) failed"
exit 1
fi
fi

say "Installing local-patch-recovery infrastructure..."

# Create dirs
mkdir -p "$HERMES_HOME/patches" "$HERMES_HOME/bin" "$HERMES_HOME/logs"
say " ✓ ~/.hermes/patches/"
say " ✓ ~/.hermes/bin/"
say " ✓ ~/.hermes/logs/"

# Install scripts
install -m 0755 "$HOOK_SRC" "$HERMES_HOME/bin/hermes-post-merge-hook.sh"
install -m 0755 "$PREUPDATE_SCRIPT" "$HERMES_HOME/bin/hermes-preupdate.sh"
install -m 0644 "$README_SRC" "$PATCH_DIR/README.md"
say " ✓ ~/.hermes/bin/hermes-post-merge-hook.sh"
say " ✓ ~/.hermes/bin/hermes-preupdate.sh"
say " ✓ ~/.hermes/patches/README.md"

# Install hook
install -m 0755 "$HOOK_SRC" "$HOOK_DEST"
say " ✓ $HOOK_DEST"

# Create manifest placeholder if missing
if [[ ! -f "$MANIFEST" ]]; then
cat > "$MANIFEST" <<'EOF'
# Local patches manifest
# Format: <full-sha> <branch-name> [description]
# See ~/.hermes/patches/README.md for usage.
#
# Example entry:
# 25222e49068daa243a45850a43e92a6498e6abf5 fix/minimax-oauth-auxiliary-routing PR #36779
EOF
say " ✓ $MANIFEST (placeholder created)"
else
say " ✓ $MANIFEST (already exists, leaving alone)"
fi

say ""
say "Done. To verify: ./scripts/install-local-patch-hooks.sh --check"
say "To uninstall: ./scripts/install-local-patch-hooks.sh --uninstall"
say "To run preflight: ~/.hermes/bin/hermes-preupdate.sh"
Loading