feat(update): auto-backup HERMES_HOME before hermes update (#16539)

Every 'hermes update' now runs a full backup of ~/.hermes/ first, so
users can always roll back to the exact state they had before the
update if anything goes wrong (corrupted sessions.db, broken skills,
config migrations that don't round-trip, etc.).

Changes:
- hermes_cli/backup.py: new create_pre_update_backup() helper. Writes
  to <HERMES_HOME>/backups/pre-update-<stamp>.zip using the same
  exclusion rules and SQLite safe-copy as 'hermes backup'. Auto-rotates
  (keep last N, pre-update-*.zip only — hand-dropped zips in backups/
  are untouched). Adds 'backups' to _EXCLUDED_DIRS so subsequent backups
  don't nest prior ones.
- hermes_cli/main.py: _run_pre_update_backup() wired into
  _cmd_update_impl before any git operation. Prints save path, restore
  command, and how to disable. Swallows failures so a broken backup
  never blocks the update itself. New --no-backup flag on 'hermes
  update' for one-off override.
- hermes_cli/config.py: new 'updates' section in DEFAULT_CONFIG with
  pre_update_backup (default true) and backup_keep (default 5).
  Auto-surfaces in the dashboard config UI.
- tests/hermes_cli/test_backup.py: +11 tests covering backup location,
  content parity with 'hermes backup', no-recursion, rotation, manual
  file preservation, config gate, --no-backup flag, flag-wins-over-config.
This commit is contained in:
Teknium 2026-04-27 05:36:19 -07:00 committed by GitHub
parent 920ebd8303
commit 8ed599dc05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 429 additions and 0 deletions

View file

@ -6142,6 +6142,90 @@ def _ensure_fhs_path_guard() -> None:
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
def _run_pre_update_backup(args) -> None:
"""Create a full zip backup of HERMES_HOME before running the update.
Gated on ``updates.pre_update_backup`` in config (default true). The
``--no-backup`` flag on ``hermes update`` overrides it for one run.
Never raises a backup failure should not block the update itself.
"""
# CLI flag wins over config
if getattr(args, "no_backup", False):
print("◆ Pre-update backup: skipped (--no-backup)")
print()
return
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception as exc:
logging.getLogger(__name__).debug("Could not load config for pre-update backup: %s", exc)
cfg = {}
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
enabled = updates_cfg.get("pre_update_backup", True)
keep = updates_cfg.get("backup_keep", 5)
if not enabled:
print("◆ Pre-update backup: disabled (updates.pre_update_backup=false in config.yaml)")
print()
return
try:
from hermes_cli.backup import create_pre_update_backup
except Exception as exc:
print(f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update.")
print()
return
print("◆ Creating pre-update backup...")
t0 = _time.monotonic()
try:
out_path = create_pre_update_backup(keep=int(keep))
except Exception as exc: # defensive — helper already swallows, but just in case
print(f" ⚠ Backup failed: {exc}")
print(" Continuing with update.")
print()
return
elapsed = _time.monotonic() - t0
if out_path is None:
print(" ⚠ Backup skipped (no files found or write failed); continuing update.")
print()
return
try:
size_bytes = out_path.stat().st_size
except OSError:
size_bytes = 0
# Human-readable size
size_str = f"{size_bytes} B"
for unit in ("KB", "MB", "GB"):
if size_bytes < 1024:
break
size_bytes /= 1024
size_str = f"{size_bytes:.1f} {unit}"
# Render path using display_hermes_home so the user sees ~/.hermes/...
try:
from hermes_constants import get_hermes_home, display_hermes_home
home = get_hermes_home()
try:
display_path = f"{display_hermes_home()}/{out_path.relative_to(home)}"
except ValueError:
display_path = str(out_path)
except Exception:
display_path = str(out_path)
print(f" Saved: {display_path} ({size_str}, {elapsed:.1f}s)")
print(f" Restore: hermes import {out_path}")
print(f" Disable: set updates.pre_update_backup: false in config.yaml")
print(f" (or pass --no-backup on a single update)")
print()
def cmd_update(args):
"""Update Hermes Agent to the latest version.
@ -6184,6 +6268,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("⚕ Updating Hermes Agent...")
print()
# Pre-update backup — runs before any git/file mutation so users can
# always roll back to the exact state they had before this update.
_run_pre_update_backup(args)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
use_zip_update = False
@ -9577,6 +9665,12 @@ Examples:
default=False,
help="Check whether an update is available without installing anything",
)
update_parser.add_argument(
"--no-backup",
action="store_true",
default=False,
help="Skip the pre-update backup for this run (overrides updates.pre_update_backup)",
)
update_parser.set_defaults(func=cmd_update)
# =========================================================================