fix(tui): avoid npm install on lockfile mtime churn

Compare package-lock.json against npm's hidden node_modules lock by content instead of mtimes. Git checkouts and npm lock rewrites can make the root lockfile newer even when installed dependencies already match, causing hermes --tui to print Installing TUI dependencies on every launch.

Made-with: Cursor
This commit is contained in:
Brooklyn Nicholson
2026-04-27 16:59:29 -05:00
parent b842272b85
commit 591a20abb1
2 changed files with 55 additions and 6 deletions

View File

@@ -830,7 +830,7 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull)."""
"""True when @hermes/ink is missing or node_modules is behind package-lock.json."""
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
if not ink.is_file():
return True
@@ -840,7 +840,32 @@ def _tui_need_npm_install(root: Path) -> bool:
marker = root / "node_modules" / ".package-lock.json"
if not marker.is_file():
return True
return lock.stat().st_mtime > marker.stat().st_mtime
try:
wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {}
installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {}
except (OSError, json.JSONDecodeError):
return lock.stat().st_mtime > marker.stat().st_mtime
ignored = {"ideallyInert"}
def comparable(pkg: dict) -> dict:
return {k: v for k, v in pkg.items() if k not in ignored}
for name, pkg in wanted.items():
if name == "":
continue
if name not in installed:
if isinstance(pkg, dict) and (pkg.get("optional") or pkg.get("peer")):
continue
return True
if isinstance(pkg, dict) and isinstance(installed[name], dict):
if comparable(pkg) != comparable(installed[name]):
return True
return False
def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:

View File

@@ -1,4 +1,4 @@
"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules."""
"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile."""
import os
from pathlib import Path
@@ -36,15 +36,39 @@ def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
assert main_mod._tui_need_npm_install(tmp_path) is True
def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None:
def test_no_install_when_lock_newer_but_hidden_lock_matches(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
(tmp_path / "package-lock.json").write_text('{"packages":{"node_modules/foo":{"version":"1.0.0"}}}')
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0","ideallyInert":true}}}'
)
os.utime(tmp_path / "package-lock.json", (200, 200))
os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100))
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_need_install_when_required_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/bar":{"version":"1.0.0"}}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0"}}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is True
def test_no_install_when_only_optional_peer_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/optional":{"version":"1.0.0","optional":true,"peer":true}}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0"}}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")