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)
|
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):
|
def cmd_update(args):
|
||||||
"""Update Hermes Agent to the latest version."""
|
"""Update Hermes Agent to the latest version."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -782,29 +872,44 @@ def cmd_update(args):
|
||||||
print("⚕ Updating Hermes Agent...")
|
print("⚕ Updating Hermes Agent...")
|
||||||
print()
|
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'
|
git_dir = PROJECT_ROOT / '.git'
|
||||||
|
|
||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
print("✗ Not a git repository. Please reinstall:")
|
if sys.platform == "win32":
|
||||||
print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash")
|
use_zip_update = True
|
||||||
sys.exit(1)
|
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"
|
# On Windows, git can fail with "unable to write loose object file: Invalid argument"
|
||||||
# due to filesystem atomicity issues. Set the recommended workaround.
|
# due to filesystem atomicity issues. Set the recommended workaround.
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32" and git_dir.exists():
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "config", "windows.appendAtomically", "false"],
|
["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"],
|
||||||
cwd=PROJECT_ROOT, check=False, capture_output=True
|
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
|
# Fetch and pull
|
||||||
try:
|
try:
|
||||||
print("→ Fetching updates...")
|
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
|
# Get current branch
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
@ -814,7 +919,7 @@ def cmd_update(args):
|
||||||
|
|
||||||
# Check if there are updates
|
# Check if there are updates
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
|
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
@ -828,7 +933,7 @@ def cmd_update(args):
|
||||||
|
|
||||||
print(f"→ Found {commit_count} new commit(s)")
|
print(f"→ Found {commit_count} new commit(s)")
|
||||||
print("→ Pulling updates...")
|
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)
|
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
|
||||||
print("→ Updating Python dependencies...")
|
print("→ Updating Python dependencies...")
|
||||||
|
|
@ -936,8 +1041,14 @@ def cmd_update(args):
|
||||||
print(" hermes model # Select provider and model")
|
print(" hermes model # Select provider and model")
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"✗ Update failed: {e}")
|
if sys.platform == "win32":
|
||||||
sys.exit(1)
|
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():
|
def main():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue