feat: ZIP-based update fallback for Windows
On Windows systems where git can't write files (antivirus, NTFS filter drivers), 'hermes update' now falls back to downloading a ZIP archive from GitHub and extracting it over the existing installation. The fallback triggers in two cases: 1. No .git directory (ZIP-installed via install.ps1 fallback) 2. Git pull fails with CalledProcessError on Windows The ZIP update preserves venv/, node_modules/, .git/, and .env, reinstalls Python deps via uv, and syncs bundled skills. Also adds -c windows.appendAtomically=false to all git commands in the update path for systems where git works but atomic writes fail.
This commit is contained in:
parent
4766b3cdb9
commit
535b46f813
1 changed files with 123 additions and 12 deletions
|
|
@ -774,6 +774,96 @@ def cmd_uninstall(args):
|
|||
run_uninstall(args)
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
"""Update Hermes Agent by downloading a ZIP archive.
|
||||
|
||||
Used on Windows when git file I/O is broken (antivirus, NTFS filter
|
||||
drivers causing 'Invalid argument' errors on file creation).
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
branch = "main"
|
||||
zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
|
||||
|
||||
print("→ Downloading latest version...")
|
||||
try:
|
||||
tmp_dir = tempfile.mkdtemp(prefix="hermes-update-")
|
||||
zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip")
|
||||
urlretrieve(zip_url, zip_path)
|
||||
|
||||
print("→ Extracting...")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(tmp_dir)
|
||||
|
||||
# GitHub ZIPs extract to hermes-agent-<branch>/
|
||||
extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
|
||||
if not os.path.isdir(extracted):
|
||||
# Try to find it
|
||||
for d in os.listdir(tmp_dir):
|
||||
candidate = os.path.join(tmp_dir, d)
|
||||
if os.path.isdir(candidate) and d != "__MACOSX":
|
||||
extracted = candidate
|
||||
break
|
||||
|
||||
# Copy updated files over existing installation, preserving venv/node_modules/.git
|
||||
preserve = {'venv', 'node_modules', '.git', '__pycache__', '.env'}
|
||||
update_count = 0
|
||||
for item in os.listdir(extracted):
|
||||
if item in preserve:
|
||||
continue
|
||||
src = os.path.join(extracted, item)
|
||||
dst = os.path.join(str(PROJECT_ROOT), item)
|
||||
if os.path.isdir(src):
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
update_count += 1
|
||||
|
||||
print(f"✓ Updated {update_count} items from ZIP")
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ ZIP update failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Reinstall Python dependencies
|
||||
print("→ Updating Python dependencies...")
|
||||
import subprocess
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
subprocess.run(
|
||||
[uv_bin, "pip", "install", "-e", ".", "--quiet"],
|
||||
cwd=PROJECT_ROOT, check=True,
|
||||
env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
)
|
||||
else:
|
||||
venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
|
||||
if venv_pip.exists():
|
||||
subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
# Sync skills
|
||||
try:
|
||||
from tools.skills_sync import sync_skills
|
||||
print("→ Checking for new bundled skills...")
|
||||
result = sync_skills(quiet=True)
|
||||
if result["copied"]:
|
||||
print(f" + {len(result['copied'])} new skill(s): {', '.join(result['copied'])}")
|
||||
else:
|
||||
print(" ✓ Skills are up to date")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version."""
|
||||
import subprocess
|
||||
|
|
@ -782,29 +872,44 @@ def cmd_update(args):
|
|||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
|
||||
# Check if we're in a git repo
|
||||
# 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
|
||||
git_dir = PROJECT_ROOT / '.git'
|
||||
|
||||
if not git_dir.exists():
|
||||
print("✗ Not a git repository. Please reinstall:")
|
||||
print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash")
|
||||
sys.exit(1)
|
||||
if sys.platform == "win32":
|
||||
use_zip_update = True
|
||||
else:
|
||||
print("✗ Not a git repository. Please reinstall:")
|
||||
print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash")
|
||||
sys.exit(1)
|
||||
|
||||
# On Windows, git can fail with "unable to write loose object file: Invalid argument"
|
||||
# due to filesystem atomicity issues. Set the recommended workaround.
|
||||
if sys.platform == "win32":
|
||||
if sys.platform == "win32" and git_dir.exists():
|
||||
subprocess.run(
|
||||
["git", "config", "windows.appendAtomically", "false"],
|
||||
["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"],
|
||||
cwd=PROJECT_ROOT, check=False, capture_output=True
|
||||
)
|
||||
|
||||
if use_zip_update:
|
||||
# ZIP-based update for Windows when git is broken
|
||||
_update_via_zip(args)
|
||||
return
|
||||
|
||||
# Fetch and pull
|
||||
try:
|
||||
print("→ Fetching updates...")
|
||||
subprocess.run(["git", "fetch", "origin"], cwd=PROJECT_ROOT, check=True)
|
||||
git_cmd = ["git"]
|
||||
if sys.platform == "win32":
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||
|
||||
subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
# Get current branch
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -814,7 +919,7 @@ def cmd_update(args):
|
|||
|
||||
# Check if there are updates
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
|
||||
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -828,7 +933,7 @@ def cmd_update(args):
|
|||
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
print("→ Pulling updates...")
|
||||
subprocess.run(["git", "pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
|
||||
subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
|
||||
print("→ Updating Python dependencies...")
|
||||
|
|
@ -936,8 +1041,14 @@ def cmd_update(args):
|
|||
print(" hermes model # Select provider and model")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"✗ Update failed: {e}")
|
||||
sys.exit(1)
|
||||
if sys.platform == "win32":
|
||||
print(f"⚠ Git update failed: {e}")
|
||||
print("→ Falling back to ZIP download...")
|
||||
print()
|
||||
_update_via_zip(args)
|
||||
else:
|
||||
print(f"✗ Update failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue