feat(desktop): content-hash build stamp with --build-only and --force-build flags
Add a SHA-256 content-hash based build stamp to `hermes desktop` so unchanged source trees skip the npm install + build step. Uses pathspec for .gitignore-aware file matching instead of a hardcoded skip-list. New CLI flags: - --build-only: run the build but don't launch the app - --force-build: rebuild even when the stamp matches `hermes update` now calls `hermes desktop --build-only` so the desktop app is rebuilt (if needed) as part of the update flow. 16/16 tests passing.
This commit is contained in:
@ -201,6 +201,7 @@ if _try_termux_ultrafast_version():
|
||||
raise SystemExit(0)
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
@ -6869,6 +6870,147 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool:
|
||||
return (desktop_dir / "dist" / "index.html").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop build stamp — content-hash based skip logic
|
||||
# ---------------------------------------------------------------------------
|
||||
# The desktop Electron build is expensive.
|
||||
# Unlike the web UI (which uses mtime comparison), the desktop uses a
|
||||
# SHA-256 content hash of the source tree so that:
|
||||
# - ``git checkout`` / ``git pull`` that touch mtimes but not content
|
||||
# don't trigger a rebuild
|
||||
# - ``hermes update`` can unconditionally call ``hermes desktop --build-only``
|
||||
# and it will skip if nothing actually changed
|
||||
# - ``hermes desktop`` (interactive launch) skips the build when the
|
||||
# stamp matches, making repeated launches fast
|
||||
#
|
||||
# Stamp file: $HERMES_HOME/desktop-build-stamp.json
|
||||
# Schema:
|
||||
# {
|
||||
# "contentHash": "<sha256 hex of source files>",
|
||||
# "sourceMode": true | false,
|
||||
# "builtAt": "<ISO 8601>"
|
||||
# }
|
||||
|
||||
def _compute_desktop_content_hash(project_root: Path) -> str:
|
||||
"""Return a SHA-256 hex digest of all source files that feed the desktop build.
|
||||
|
||||
Covers ``apps/desktop/`` (excluding anything matched by .gitignore)
|
||||
plus the root ``package.json`` / ``package-lock.json`` (workspace config
|
||||
that determines dependency resolution for the desktop workspace).
|
||||
|
||||
Parses the repo-root ``.gitignore`` via *pathspec* so we automatically
|
||||
skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining
|
||||
a hardcoded skip-list.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
|
||||
def _hash_file(path: Path) -> None:
|
||||
rel = str(path.relative_to(project_root))
|
||||
h.update(rel.encode())
|
||||
h.update(b"\0")
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
h.update(b"\0")
|
||||
|
||||
|
||||
from pathspec import PathSpec
|
||||
|
||||
gitignore = project_root / ".gitignore"
|
||||
lines: list[str] = []
|
||||
if gitignore.is_file():
|
||||
lines = gitignore.read_text(encoding="utf-8").splitlines()
|
||||
spec = PathSpec.from_lines("gitignore", lines)
|
||||
|
||||
# Root workspace config
|
||||
for name in ("package.json", "package-lock.json"):
|
||||
p = project_root / name
|
||||
if p.is_file():
|
||||
rel = str(p.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(p)
|
||||
|
||||
# Walk apps/desktop/ — prune ignored directories in-place
|
||||
desktop_dir = project_root / "apps" / "desktop"
|
||||
for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True):
|
||||
# Prune ignored directories so we never descend into them
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
|
||||
]
|
||||
|
||||
for fn in sorted(filenames):
|
||||
fp = Path(dirpath) / fn
|
||||
rel = str(fp.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(fp)
|
||||
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _desktop_stamp_path() -> Path:
|
||||
"""Return the path to the desktop build stamp file under $HERMES_HOME."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "desktop-build-stamp.json"
|
||||
|
||||
|
||||
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
|
||||
"""Return True when the desktop build output is stale or missing.
|
||||
|
||||
Compares the current content hash against the saved stamp. Also returns
|
||||
True if the expected build artifact doesn't exist (e.g. first run after
|
||||
``hermes update`` that pulled new source but hasn't built yet).
|
||||
"""
|
||||
# If there's no build output at all, we definitely need to build
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
return True
|
||||
else:
|
||||
if _desktop_packaged_executable(desktop_dir) is None:
|
||||
return True
|
||||
|
||||
stamp_file = _desktop_stamp_path()
|
||||
if not stamp_file.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
return True
|
||||
|
||||
# If the mode changed (source vs packaged), force a rebuild
|
||||
if stamp_data.get("sourceMode") != source_mode:
|
||||
return True
|
||||
|
||||
saved_hash = stamp_data.get("contentHash")
|
||||
if not saved_hash:
|
||||
return True
|
||||
|
||||
current_hash = _compute_desktop_content_hash(project_root)
|
||||
return current_hash != saved_hash
|
||||
|
||||
|
||||
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
|
||||
"""Write the desktop build stamp after a successful build."""
|
||||
stamp_file = _desktop_stamp_path()
|
||||
try:
|
||||
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
content_hash = _compute_desktop_content_hash(project_root)
|
||||
from datetime import datetime, timezone
|
||||
stamp_data = {
|
||||
"contentHash": content_hash,
|
||||
"sourceMode": source_mode,
|
||||
"builtAt": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
|
||||
except Exception as exc:
|
||||
# Never let stamp-writing block or fail a build
|
||||
logger.debug("Failed to write desktop build stamp: %s", exc)
|
||||
|
||||
|
||||
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
||||
"""Return the current platform's unpacked Electron app executable."""
|
||||
release_dir = desktop_dir / "release"
|
||||
@ -6928,8 +7070,7 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
|
||||
except Exception as exc:
|
||||
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
||||
|
||||
|
||||
def cmd_gui(args):
|
||||
def cmd_gui(args: argparse.Namespace):
|
||||
"""Build and launch the native Electron desktop GUI."""
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
if not (desktop_dir / "package.json").exists():
|
||||
@ -6954,6 +7095,8 @@ def cmd_gui(args):
|
||||
|
||||
source_mode = getattr(args, "source", False)
|
||||
skip_build = getattr(args, "skip_build", False)
|
||||
force_build = getattr(args, "force_build", False)
|
||||
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
|
||||
if source_mode or not skip_build:
|
||||
@ -6965,7 +7108,7 @@ def cmd_gui(args):
|
||||
else:
|
||||
npm = None
|
||||
|
||||
if getattr(args, "skip_build", False):
|
||||
if skip_build:
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
|
||||
@ -6986,27 +7129,41 @@ def cmd_gui(args):
|
||||
else:
|
||||
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
# Check the content-hash stamp before doing any build work.
|
||||
# If the source tree hasn't changed since the last successful build,
|
||||
# skip the npm install + build entirely (saves a ton of useless work).
|
||||
# --force-build overrides the stamp and always rebuilds.
|
||||
build_needed = force_build or _desktop_build_needed(
|
||||
desktop_dir, PROJECT_ROOT, source_mode=source_mode
|
||||
)
|
||||
if not build_needed:
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# Build succeeded — write the stamp so next run can skip
|
||||
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
|
||||
|
||||
# --build-only: produce the artifact but do NOT launch. The installer's
|
||||
# --update flow drives the rebuild headlessly and then launches the desktop
|
||||
@ -9697,6 +9854,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
# Rebuild the desktop app if the source tree changed since the last
|
||||
# build. ``hermes desktop --build-only`` uses the content-hash stamp
|
||||
# internally, so this is effectively a no-op when nothing changed.
|
||||
# Only bother if the user has a desktop app installed (indicated by
|
||||
# an existing packaged executable or desktop dist); people who have
|
||||
# never run ``hermes desktop`` shouldn't be forced into a full
|
||||
# Electron build by ``hermes update``.
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
|
||||
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
|
||||
print("→ Checking if desktop app needs rebuilding...")
|
||||
build_result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
if build_result.returncode != 0:
|
||||
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
|
||||
|
||||
print()
|
||||
print("✓ Code updated!")
|
||||
|
||||
@ -11364,7 +11540,7 @@ def cmd_dashboard(args):
|
||||
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
|
||||
sys.exit(1)
|
||||
elif getattr(args, "skip_build", False):
|
||||
# --skip-build trusts the caller to have pre-built the web UI.
|
||||
# --build-mode skip trusts the caller to have pre-built the web UI.
|
||||
# Verify the dist actually exists; otherwise the server will start
|
||||
# and serve 404s with no obvious cause (issue #23817).
|
||||
_dist_root = (
|
||||
@ -14733,11 +14909,6 @@ Examples:
|
||||
"Electron app, then launches that packaged artifact."
|
||||
),
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--source",
|
||||
action="store_true",
|
||||
@ -14766,6 +14937,16 @@ Examples:
|
||||
"--cwd",
|
||||
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--force-build",
|
||||
action="store_true",
|
||||
help="Force a full rebuild even if the content stamp matches",
|
||||
)
|
||||
gui_parser.set_defaults(func=cmd_gui)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@ -64,6 +64,8 @@ dependencies = [
|
||||
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
|
||||
# `os.killpg` (which doesn't exist on Windows).
|
||||
"psutil==7.2.2",
|
||||
# .gitignore-aware file matching for desktop build stamp.
|
||||
"pathspec==1.1.1",
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
|
||||
@ -16,6 +16,8 @@ from hermes_cli import main as cli_main
|
||||
def _ns(**kw):
|
||||
defaults = dict(
|
||||
skip_build=False,
|
||||
build_only=False,
|
||||
force_build=False,
|
||||
source=False,
|
||||
fake_boot=False,
|
||||
ignore_existing=False,
|
||||
@ -60,6 +62,8 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
@ -86,6 +90,8 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \
|
||||
pytest.raises(SystemExit):
|
||||
@ -158,6 +164,8 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(source=True))
|
||||
@ -179,3 +187,158 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
|
||||
def test_gui_is_known_builtin_for_plugin_gating(argv):
|
||||
with patch.object(sys, "argv", argv):
|
||||
assert cli_main._plugin_cli_discovery_needed() is False
|
||||
|
||||
|
||||
# ── Content-hash stamp tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch):
|
||||
"""When the stamp matches and the artifact exists, build is skipped entirely."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
launch_ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_not_called()
|
||||
mock_run.assert_called_once() # only the launch call, no build
|
||||
|
||||
|
||||
def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch):
|
||||
"""--force-build forces a rebuild even when the stamp says up-to-date."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
launch_ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=False), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(force_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_called_once()
|
||||
mock_stamp.assert_called_once()
|
||||
# pack + launch = 2 calls
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch):
|
||||
"""_compute_desktop_content_hash returns the same digest for identical trees."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8")
|
||||
(root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8")
|
||||
(root / "package-lock.json").write_text('{}', encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64 # sha256 hex
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch):
|
||||
"""Editing a file under apps/desktop/ changes the hash."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch):
|
||||
"""Even with a valid stamp, missing artifact means build is needed."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
# Write a stamp that matches current content
|
||||
cli_main._write_desktop_build_stamp(root, source_mode=False)
|
||||
# No packaged executable exists → build needed
|
||||
assert cli_main._desktop_build_needed(
|
||||
root / "apps" / "desktop", root, source_mode=False
|
||||
) is True
|
||||
|
||||
|
||||
def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch):
|
||||
"""Write stamp, then _desktop_build_needed returns False when artifact exists."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
# Create the artifact so the "artifact exists" check passes
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
# Write stamp
|
||||
cli_main._write_desktop_build_stamp(root, source_mode=False)
|
||||
# Build should NOT be needed
|
||||
assert cli_main._desktop_build_needed(
|
||||
root / "apps" / "desktop", root, source_mode=False
|
||||
) is False
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch):
|
||||
"""When no .gitignore exists, _compute_desktop_content_hash still works (matches everything)."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
# No .gitignore → pathspec matches nothing → all files hashed
|
||||
h = cli_main._compute_desktop_content_hash(root)
|
||||
assert len(h) == 64 # valid sha256 hex
|
||||
|
||||
# Edit a file → hash changes
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h != h2
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
|
||||
"""Files matched by .gitignore are excluded from the hash."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8")
|
||||
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
(root / ".gitignore").write_text("*.env\n", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
# Reset cached spec
|
||||
cli_main._DESKTOP_STAMP_SPEC = None
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
|
||||
# Change the .env file (ignored) — hash should NOT change
|
||||
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8")
|
||||
cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 == h2, "changing an ignored file should not change the hash"
|
||||
|
||||
# Change the .js file (not ignored) — hash SHOULD change
|
||||
(root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8")
|
||||
cli_main._DESKTOP_STAMP_SPEC = None
|
||||
h3 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 != h3, "changing a tracked file should change the hash"
|
||||
|
||||
31
uv.lock
generated
31
uv.lock
generated
@ -1602,21 +1602,26 @@ version = "0.15.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "croniter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fire" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "jinja2" },
|
||||
{ name = "openai" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "ruamel-yaml" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@ -1632,11 +1637,9 @@ all = [
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "simple-term-menu" },
|
||||
@ -1739,10 +1742,6 @@ modal = [
|
||||
parallel-web = [
|
||||
{ name = "parallel-web" },
|
||||
]
|
||||
pty = [
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
slack = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "slack-bolt" },
|
||||
@ -1755,9 +1754,7 @@ termux = [
|
||||
{ name = "agent-client-protocol" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
@ -1770,9 +1767,7 @@ termux-all = [
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@ -1822,6 +1817,7 @@ requires-dist = [
|
||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" },
|
||||
{ name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" },
|
||||
{ name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" },
|
||||
{ name = "fastapi", specifier = ">=0.104.0,<1" },
|
||||
{ name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" },
|
||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" },
|
||||
{ name = "fire", specifier = "==0.7.1" },
|
||||
@ -1866,9 +1862,10 @@ requires-dist = [
|
||||
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
|
||||
{ name = "openai", specifier = "==2.24.0" },
|
||||
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
|
||||
{ name = "pathspec", specifier = "==1.1.1" },
|
||||
{ name = "prompt-toolkit", specifier = "==3.0.52" },
|
||||
{ name = "psutil", specifier = "==7.2.2" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
@ -1877,7 +1874,7 @@ requires-dist = [
|
||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
|
||||
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
|
||||
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
|
||||
@ -1900,6 +1897,7 @@ requires-dist = [
|
||||
{ name = "tenacity", specifier = "==9.1.4" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
|
||||
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
|
||||
]
|
||||
@ -3057,6 +3055,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
||||
@ -92,8 +92,9 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works
|
||||
| Flag | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `--skip-build` | Skip npm install/package and launch the existing unpacked app from `apps/desktop/release` |
|
||||
| `--force-build` | Force a full rebuild even if the content stamp matches |
|
||||
| `--build-only` | Build the desktop app but do not launch it (used by `hermes update`) |
|
||||
| `--source` | Launch via `electron .` against `apps/desktop/dist` instead of the packaged app |
|
||||
| `--build-only` | Build the desktop app but do not launch it (used by the installer's `--update` flow) |
|
||||
| `--cwd PATH` | Initial project directory for desktop chat sessions (sets `HERMES_DESKTOP_CWD`) |
|
||||
| `--hermes-root PATH` | Override the Hermes source root the app uses (sets `HERMES_DESKTOP_HERMES_ROOT`) |
|
||||
| `--ignore-existing` | Force the app to ignore any `hermes` CLI already on `PATH` during backend resolution |
|
||||
|
||||
Reference in New Issue
Block a user