diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3007cd9c4d..63294e9c52 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -871,6 +871,7 @@ from hermes_cli.main_desktop import ( # frozen updater surface: update_cmd*.py _desktop_macos_relaunchable_fixup, _desktop_packaged_executable, _install_rebuilt_desktop_app, + _installed_desktop_apps, ) from hermes_cli.main_web_build import ( _sweep_stale_bytecode_if_checkout_changed, diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 1bb6334ca6..9a2f03033f 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -8,6 +8,7 @@ import logging import contextlib import argparse import hashlib +import json import os import platform import re @@ -947,10 +948,44 @@ def _install_rebuilt_desktop_app(desktop_dir: Path) -> tuple[list[Path], list[st rebuilt_exe = _desktop_packaged_executable(desktop_dir) if rebuilt_exe is None: return [], [] - from hermes_cli.gui_uninstall import packaged_gui_app_paths # noqa: PLC0415 # .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app return _install_rebuilt_macos_bundles( - rebuilt_exe.parents[2], packaged_gui_app_paths(), running=_running_macos_app_bundles()) + rebuilt_exe.parents[2], _installed_desktop_apps(), running=_running_macos_app_bundles()) + + +def _update_owned_macos_bundles(candidates: list[Path]) -> list[Path]: + """The existing bundles in *candidates* that only ``hermes update`` keeps current (#52339). + + Ownership comes from the bundle's own ``install-stamp.json``. ``updateMechanism: self`` is a + bootstrap build (a local pack or the bootstrap download), and stamps older than the field + predate every self-updating kind. Bundled/light releases update themselves and commit builds + are external, so a local build must never be copied over them. No readable stamp, no claim. + """ + owned = [] + for app in candidates: + try: + stamp = json.loads((app / "Contents" / "Resources" / "install-stamp.json").read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + continue + if isinstance(stamp, dict) and stamp.get("updateMechanism", "self") == "self": + owned.append(app) + return owned + + +def _installed_desktop_apps() -> list[Path]: + """Installed macOS ``Hermes.app`` bundles this checkout's update owns (none off macOS). + + A packaged app runs the checkout under the default Hermes home, so only that checkout may + build for it: a bundle from any other tree (a dev worktree) would split shell from backend. + """ + if sys.platform != "darwin": + return [] + from hermes_cli.gui_uninstall import packaged_gui_app_paths # noqa: PLC0415 + from hermes_cli.main import PROJECT_ROOT # noqa: PLC0415 + from hermes_constants import get_default_hermes_root # noqa: PLC0415 + if Path(PROJECT_ROOT).resolve() != (get_default_hermes_root() / "hermes-agent").resolve(): + return [] + return _update_owned_macos_bundles(packaged_gui_app_paths()) def _install_rebuilt_macos_bundles( diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 2cfcabeda2..d8ece26c72 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1286,9 +1286,12 @@ def _cmd_update_impl(args, gateway_mode: bool): desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop" + # An installed Hermes.app only this update refreshes counts even with no release/ build + # beside it: without one it was never rebuilt, so it never got newer (#52339). had_desktop_app_before_update = ( _m()._desktop_packaged_executable(desktop_dir) is not None - or _m()._desktop_dist_exists(desktop_dir)) + or _m()._desktop_dist_exists(desktop_dir) + or bool(_m()._installed_desktop_apps())) use_zip_update, git_cmd, is_fork = _prepare_git_command() diff --git a/tests/hermes_cli/test_update_zip_completion.py b/tests/hermes_cli/test_update_zip_completion.py index 78a58da1cf..007800f4b4 100644 --- a/tests/hermes_cli/test_update_zip_completion.py +++ b/tests/hermes_cli/test_update_zip_completion.py @@ -313,3 +313,28 @@ def test_venv_layout_explicit_and_native(tmp_path, windows, folder, executable): assert venv_python(str(tmp_path), windows=windows) == tmp_path / folder / executable if windows == (os.name == "nt"): assert venv_python(tmp_path) == tmp_path / folder / executable + + +@pytest.mark.platforms("macos") +@pytest.mark.parametrize(("mechanism", "rebuilt"), [("self", True), ("electron-updater", False)]) +def test_installed_app_without_a_checkout_build_is_still_rebuilt(zip_update, monkeypatch, tmp_path, mechanism, rebuilt): + """#52339: an installed Hermes.app only ``hermes update`` refreshes needs a Desktop build even + when release/ is gone, or it never gets newer. A self-updating release is not ours to rebuild.""" + installed = tmp_path / "Applications" / "Hermes.app" + (installed / "Contents" / "Resources").mkdir(parents=True) + (installed / "Contents" / "Resources" / "install-stamp.json").write_text( + json.dumps({"updateMechanism": mechanism}), encoding="utf-8") + monkeypatch.setattr("hermes_cli.gui_uninstall.packaged_gui_app_paths", lambda: [installed]) + # The checkout under the default Hermes home is the one an installed app runs. + (tmp_path / "default-home").mkdir() + (tmp_path / "default-home" / "hermes-agent").symlink_to(zip_update.root) + monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda **kw: tmp_path / "default-home") + built = [] + monkeypatch.setattr("hermes_cli.source_build.build_update_products", + lambda selected, *, desktop: built.append(desktop)) + monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, ["git"], False)) + monkeypatch.setattr(main, "_warn_orphaned_update_autostashes", lambda *args: None) + + update_cmd._cmd_update_impl(SimpleNamespace(branch="main", yes=True), False) + + assert built == [rebuilt]