mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:37:05 +08:00
`_tui_need_npm_install()` compares the canonical `package-lock.json` against
the hidden `node_modules/.package-lock.json` to decide whether `npm install`
needs to re-run. npm 9 drops the `"peer": true` field from the hidden lock
on dev-deps that are *also* declared as peers (the canonical lock preserves
the dual annotation). That made the check flag 16 packages (`@babel/core`,
`@types/node`, `@types/react`, `@typescript-eslint/*`, `react`, `vite`,
`tsx`, `typescript`, …) as mismatched on every launch, triggering a runtime
`npm install`.
Inside the Docker image, that runtime install then fails with EACCES because
`/opt/hermes/ui-tui/node_modules/` is root-owned from build time, so
`docker run … hermes-agent --tui` prints:
Installing TUI dependencies…
npm install failed.
…and exits 1, with no preview. The empty preview is a second bug: the
launcher captured only stderr, but npm 9 writes EACCES to stdout, which
was DEVNULL'd.
Fixes:
- Add `"peer"` to `_NPM_LOCK_RUNTIME_KEYS` so the comparison ignores the
non-deterministic field, alongside the existing `"ideallyInert"`.
- Capture stdout as well as stderr in the install subprocess so future
failures surface a useful preview instead of a bare "failed." line.
Regression tests:
- `test_no_install_when_only_peer_annotation_differs` — the exact scenario
- `test_install_when_version_differs_even_with_peer_drop` — guards against
the peer-drop tolerance masking a real version skew
On-host impact: the same false-positive was firing on every `hermes --tui`
invocation from a normal checkout, silently running a no-op `npm install`
each time (it converged because the host's `node_modules/` is writable).
Startup time on the TUI should drop noticeably.
139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def main_mod():
|
|
import hermes_cli.main as m
|
|
|
|
return m
|
|
|
|
|
|
def _touch_ink(root: Path) -> None:
|
|
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
|
ink.parent.mkdir(parents=True, exist_ok=True)
|
|
ink.write_text("{}")
|
|
|
|
|
|
def _touch_tui_entry(root: Path) -> None:
|
|
entry = root / "dist" / "entry.js"
|
|
entry.parent.mkdir(parents=True, exist_ok=True)
|
|
entry.write_text("console.log('tui')")
|
|
|
|
|
|
def _touch_ink_bundle(root: Path) -> None:
|
|
bundle = root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js"
|
|
bundle.parent.mkdir(parents=True, exist_ok=True)
|
|
bundle.write_text("export {}")
|
|
|
|
|
|
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
|
|
(tmp_path / "package-lock.json").write_text("{}")
|
|
assert main_mod._tui_need_npm_install(tmp_path) is True
|
|
|
|
|
|
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('{"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_only_peer_annotation_differs(tmp_path: Path, main_mod) -> None:
|
|
"""npm 9 drops the ``peer`` flag from the hidden lock on dev-deps that are
|
|
*also* declared as peers. That's a cosmetic difference — the package is
|
|
installed at the requested version — so it must not trigger a reinstall.
|
|
Regression for the TUI-in-Docker failure where 16 such mismatches caused
|
|
`Installing TUI dependencies…` → EACCES on every launch.
|
|
"""
|
|
_touch_ink(tmp_path)
|
|
(tmp_path / "package-lock.json").write_text(
|
|
'{"packages":{'
|
|
'"node_modules/foo":{"version":"1.0.0","dev":true,"peer":true,"resolved":"https://x/foo.tgz"}'
|
|
'}}'
|
|
)
|
|
(tmp_path / "node_modules" / ".package-lock.json").write_text(
|
|
'{"packages":{'
|
|
'"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}'
|
|
'}}'
|
|
)
|
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
|
|
|
|
|
def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None:
|
|
"""The peer-drop tolerance must not mask a real version skew."""
|
|
_touch_ink(tmp_path)
|
|
(tmp_path / "package-lock.json").write_text(
|
|
'{"packages":{"node_modules/foo":{"version":"2.0.0","dev":true,"peer":true}}}'
|
|
)
|
|
(tmp_path / "node_modules" / ".package-lock.json").write_text(
|
|
'{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}'
|
|
)
|
|
assert main_mod._tui_need_npm_install(tmp_path) is True
|
|
|
|
|
|
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("{}")
|
|
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
|
|
os.utime(tmp_path / "package-lock.json", (100, 100))
|
|
os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200))
|
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
|
|
|
|
|
def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
|
|
_touch_ink(tmp_path)
|
|
(tmp_path / "package-lock.json").write_text("{}")
|
|
assert main_mod._tui_need_npm_install(tmp_path) is True
|
|
|
|
|
|
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
|
|
_touch_ink(tmp_path)
|
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
|
|
|
|
|
def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None:
|
|
_touch_tui_entry(tmp_path)
|
|
_touch_ink(tmp_path)
|
|
|
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
|
assert main_mod._tui_build_needed(tmp_path) is True
|
|
|
|
|
|
def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None:
|
|
_touch_tui_entry(tmp_path)
|
|
_touch_ink(tmp_path)
|
|
_touch_ink_bundle(tmp_path)
|
|
|
|
assert main_mod._tui_build_needed(tmp_path) is False
|