fix(cli): inline the $HERMES_HOME/npmrc lookup, trim tests to two, document it

Fold the helper into _npm_lifecycle_env itself: the whole fix is one
is_file() check plus a setdefault, so a separate function, the
try/except around get_hermes_home() (it never raises) and the
dict-returning indirection were shape-gate violations. Build the path
with os.fspath so it is correct on Windows too.

Tests: keep the two invariants (file present -> NPM_CONFIG_USERCONFIG
points at it; explicit process/caller value wins and a missing file
sets nothing), fold the other two into the negative test.

Docs: one paragraph in the desktop troubleshooting page next to
ELECTRON_MIRROR describing $HERMES_HOME/npmrc.

Refs #106373
This commit is contained in:
teknium1
2026-09-09 04:42:40 -07:00
committed by Teknium
parent ce4a33a9f7
commit a785db3672
3 changed files with 21 additions and 56 deletions
+10 -25
View File
@@ -420,37 +420,22 @@ def _ensure_tui_workspace(tui_dir: Path) -> None:
sys.exit(1) sys.exit(1)
def _persistent_npm_userconfig() -> Optional[dict[str, str]]:
"""``NPM_CONFIG_USERCONFIG`` for ``$HERMES_HOME/npmrc`` when it exists.
The repo-root ``.npmrc`` is git-tracked, so the updater's autostash parks
any mirror/proxy line added there and every update reinstalls without it
(restricted networks then prune optional native deps like get-windows and
the rebuild fails). ``$HERMES_HOME`` lives outside the git tree, survives
autostash/reset, and the update hand-off already carries ``HERMES_HOME``
down to every npm child. An explicit ``NPM_CONFIG_USERCONFIG`` wins.
"""
from hermes_constants import get_hermes_home
if os.environ.get("NPM_CONFIG_USERCONFIG", "").strip():
return None
try:
candidate = get_hermes_home() / "npmrc"
except Exception:
return None
if not candidate.is_file():
return None
return {"NPM_CONFIG_USERCONFIG": str(candidate)}
def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]: def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]:
"""Build a clean environment for the pinned UI toolchain lifecycle.""" """Build a clean environment for the pinned UI toolchain lifecycle."""
run_env = {**os.environ, **(env or {}), "CI": "1"} run_env = {**os.environ, **(env or {}), "CI": "1"}
# esbuild treats this as an executable override. If a shell points it at a # esbuild treats this as an executable override. If a shell points it at a
# different release, the pinned package's postinstall rejects that binary. # different release, the pinned package's postinstall rejects that binary.
run_env.pop("ESBUILD_BINARY_PATH", None) run_env.pop("ESBUILD_BINARY_PATH", None)
for key, value in (_persistent_npm_userconfig() or {}).items(): # The repo-root ``.npmrc`` is git-tracked, so the updater's autostash parks
run_env.setdefault(key, value) # any mirror/proxy line added there and every update reinstalls without it
# (restricted networks then prune optional native deps like get-windows and
# the rebuild fails). ``$HERMES_HOME`` lives outside the git tree and the
# update hand-off already carries ``HERMES_HOME`` down to every npm child.
# An explicit ``NPM_CONFIG_USERCONFIG`` wins (#106373).
from hermes_constants import get_hermes_home
npmrc = get_hermes_home() / "npmrc"
if npmrc.is_file():
run_env.setdefault("NPM_CONFIG_USERCONFIG", os.fspath(npmrc))
return run_env return run_env
+9 -31
View File
@@ -781,56 +781,34 @@ def test_make_tui_argv_omits_workspace_and_scrubs_esbuild_override(
class TestPersistentNpmUserconfig: class TestPersistentNpmUserconfig:
"""$HERMES_HOME/npmrc must reach every npm lifecycle child process.""" """$HERMES_HOME/npmrc must reach every npm lifecycle child process (#106373)."""
def test_hermes_home_npmrc_sets_userconfig(self, tmp_path, monkeypatch): def test_hermes_home_npmrc_sets_userconfig(self, tmp_path, monkeypatch):
home = tmp_path / "home" home = tmp_path / "home"
home.mkdir() home.mkdir()
(home / "npmrc").write_text( (home / "npmrc").write_text(
"node_get_windows_binary_host_mirror=https://ghproxy.net/https://github.com/sindresorhus/get-windows/releases/download/\n", "node_get_windows_binary_host_mirror=https://mirror.example/get-windows/\n",
encoding="utf-8", encoding="utf-8",
) )
monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False) monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
monkeypatch.delenv("ESBUILD_BINARY_PATH", raising=False)
env = main_tui_launch._npm_lifecycle_env() env = main_tui_launch._npm_lifecycle_env()
assert env["NPM_CONFIG_USERCONFIG"] == str(home / "npmrc") assert env["NPM_CONFIG_USERCONFIG"] == os.fspath(home / "npmrc")
def test_missing_npmrc_leaves_env_untouched(self, tmp_path, monkeypatch): def test_explicit_userconfig_wins_and_missing_file_sets_nothing(self, tmp_path, monkeypatch):
home = tmp_path / "home" home = tmp_path / "home"
home.mkdir() home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False) monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
env = main_tui_launch._npm_lifecycle_env() assert "NPM_CONFIG_USERCONFIG" not in main_tui_launch._npm_lifecycle_env()
assert "NPM_CONFIG_USERCONFIG" not in env (home / "npmrc").write_text("registry=https://example.invalid\n", encoding="utf-8")
def test_explicit_userconfig_wins(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
(home / "npmrc").write_text(
"registry=https://example.invalid\n", encoding="utf-8"
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / "custom-npmrc")) monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / "custom-npmrc"))
assert main_tui_launch._npm_lifecycle_env()["NPM_CONFIG_USERCONFIG"] == str(tmp_path / "custom-npmrc")
env = main_tui_launch._npm_lifecycle_env() monkeypatch.delenv("NPM_CONFIG_USERCONFIG")
env = main_tui_launch._npm_lifecycle_env({"NPM_CONFIG_USERCONFIG": "/from-caller/npmrc"})
assert env["NPM_CONFIG_USERCONFIG"] == str(tmp_path / "custom-npmrc")
def test_caller_env_does_not_override_userconfig(self, tmp_path, monkeypatch):
env_in = {"NPM_CONFIG_USERCONFIG": "/from-caller/npmrc"}
home = tmp_path / "home"
home.mkdir()
(home / "npmrc").write_text(
"registry=https://example.invalid\n", encoding="utf-8"
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
env = main_tui_launch._npm_lifecycle_env(env_in)
assert env["NPM_CONFIG_USERCONFIG"] == "/from-caller/npmrc" assert env["NPM_CONFIG_USERCONFIG"] == "/from-caller/npmrc"
+2
View File
@@ -539,6 +539,8 @@ ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ \
bash -c 'cd "$HOME/.hermes/hermes-agent/apps/desktop" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack' bash -c 'cd "$HOME/.hermes/hermes-agent/apps/desktop" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'
``` ```
**Other native downloads (e.g. the `get-windows` prebuilt on Windows) that need a mirror:** put the npm keys in `$HERMES_HOME/npmrc` (`%LOCALAPPDATA%\hermes\npmrc` on Windows, `~/.hermes/npmrc` elsewhere) — for example `node_get_windows_binary_host_mirror=https://<mirror>/sindresorhus/get-windows/releases/download/`. Every `npm ci`/`npm run` the updater spawns (desktop, web and TUI builds) points `NPM_CONFIG_USERCONFIG` at that file when it exists, so the config survives `hermes update`; the repo-root `.npmrc` is git-tracked and gets autostashed on every update, and `~/.npmrc` may be missed because the desktop hand-off inherits the GUI's environment. An `NPM_CONFIG_USERCONFIG` you set yourself is never overridden.
To clear a corrupt cached zip by hand: To clear a corrupt cached zip by hand:
```bash ```bash