From 53a785449fc52684073ff29cf4caba19fdcd2672 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 25 Sep 2026 00:04:46 -0500 Subject: [PATCH] fix(update): rebuild Desktop for an installed app with no checkout build `hermes update` only rebuilt Desktop when apps/desktop/release/ or dist/ existed, so an installed Hermes.app that only the checkout update refreshes (a bootstrap build) never got newer once release/ was gone or never built. Count such bundles as a Desktop to keep current. Ownership is read from the bundle's install-stamp.json: `updateMechanism: self` (or a stamp older than the field). Self-updating releases and commit builds are never rebuilt or copied over, and only the checkout an installed app actually runs (the one under the default Hermes home) claims it. The post-build install now uses the same set. --- hermes_cli/main.py | 1 + hermes_cli/main_desktop.py | 39 ++++++++++++++++++- hermes_cli/update_cmd.py | 5 ++- .../hermes_cli/test_update_zip_completion.py | 25 ++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) 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]