From a4ee1f223d5f3c0cae13a98c7214daefc2836145 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:21:20 +0700 Subject: [PATCH 1/3] fix(install): make `npm install -g` packages reachable on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the installer falls back to a bundled Node under $HERMES_HOME/node, npm's default global prefix is that Node dir, so `npm install -g ` drops the package binary in $HERMES_HOME/node/bin. Only node/npm/npx are symlinked into the command link dir (~/.local/bin, /usr/local/bin, or $PREFIX/bin) — so user-installed global package binaries are NOT on PATH and can't be run, even though `npm i -g` reports success. They also get wiped on every Node upgrade (the dir is rm -rf'd and re-extracted). Redirect the bundled Node's npm global prefix to the command link dir's parent, so global bins land in the link dir (already on PATH, alongside node/npm/npx) and survive Node upgrades. Scoped to the bundled Node via its prefix-local global npmrc ($HERMES_HOME/node/etc/npmrc), so the user's other Node installs and their ~/.npmrc are untouched. Hermes's own global installs (agent-browser) pass an explicit --prefix and are unaffected. --- scripts/install.sh | 12 ++++++++++++ scripts/lib/node-bootstrap.sh | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 7d644fe2d77..030d57d4c14 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -851,6 +851,18 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" + # Point this Node's `npm install -g` at a directory that is actually on + # PATH. By default npm's global prefix is the Node install dir, so user + # globals land in $HERMES_HOME/node/bin — which is NOT on PATH (only the + # link dir is) and is wiped on every Node upgrade. Redirecting the prefix + # to the link dir's parent makes global bins land in the link dir + # (node/npm/npx live there too, and it's already on PATH) and survive + # upgrades. Scoped to this Node via its prefix-local global npmrc, so the + # user's other Node installs and their ~/.npmrc are untouched. Hermes's + # own global installs pass an explicit --prefix and are unaffected. + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + export PATH="$HERMES_HOME/node/bin:$PATH" local installed_ver diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 02e568733f3..15763d70486 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -206,6 +206,14 @@ _nb_install_bundled_node() { ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node" ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" + + # Redirect this Node's `npm install -g` to the link dir (already on PATH) + # instead of the default $HERMES_HOME/node/bin, which is off PATH and wiped + # on every Node upgrade. Scoped to this Node via its prefix-local global + # npmrc; the user's other Node installs / ~/.npmrc are untouched. + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + export PATH="$HERMES_HOME/node/bin:$PATH" _nb_have_modern_node || return 1 From 98205da008601525a658a106edb3c26199beb2b7 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:21:25 +0700 Subject: [PATCH 2/3] test(install): cover bundled-Node npm global prefix redirect Guards that install.sh and node-bootstrap.sh redirect the bundled Node's npm global prefix to the command link dir's parent via a prefix-local global npmrc, so `npm install -g` binaries land on PATH instead of the off-PATH $HERMES_HOME/node/bin. --- tests/test_install_sh_node_global_prefix.py | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_install_sh_node_global_prefix.py diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py new file mode 100644 index 00000000000..f604fc97d77 --- /dev/null +++ b/tests/test_install_sh_node_global_prefix.py @@ -0,0 +1,39 @@ +"""Regression tests for the Hermes-managed Node's npm global prefix. + +When the installer falls back to a bundled Node under ``$HERMES_HOME/node``, +npm's default global prefix is that Node dir, so ``npm install -g `` +drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH +(only the command link dir is) and is wiped on every Node upgrade. Users then +report "I can ``npm i -g`` but the package isn't usable on the command line". + +The fix redirects the bundled Node's global prefix to the command link dir's +parent (so global bins land in the already-on-PATH link dir alongside +node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" +NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + + +def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = INSTALL_SH.read_text() + + # The redirect must target the link dir's PARENT so global bins resolve to + # /bin == the command link dir (node/npm/npx live there and it is + # guaranteed on PATH by the installer's PATH setup). + assert 'printf \'prefix=%s\\n\' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + + # The npmrc lives under the bundled Node so it only affects this npm, not + # the user's other Node installs or their ~/.npmrc. + assert '"$HERMES_HOME/node/etc/npmrc"' in text + + +def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = NODE_BOOTSTRAP.read_text() + + assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + assert '"$HERMES_HOME/node/etc/npmrc"' in text From 1db8f7ea8094d35ee9afdf81c6bd3b41d2fced1b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:34:11 +0700 Subject: [PATCH 3/3] fix(install): repair existing managed-Node global prefix on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial fix only wrote the prefix npmrc on a fresh Node install, so pre-existing bundled-Node installs (Node already present) were not repaired by re-running the installer — install_node/ensure_node skip when Node is already up to date. Extract the redirect into an idempotent helper (configure_managed_node_npm_prefix / _nb_configure_npm_prefix) that no-ops when there's no Hermes-managed npm, and call it unconditionally from check_node (install.sh) and at the top of ensure_node (node-bootstrap.sh). Re-running the install command now repairs an affected install in place, not just brand-new ones. --- scripts/install.sh | 36 ++++++++++++++------- scripts/lib/node-bootstrap.sh | 24 ++++++++++---- tests/test_install_sh_node_global_prefix.py | 26 ++++++++++++--- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 030d57d4c14..b3b5f104e3d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -413,6 +413,25 @@ get_command_link_display_dir() { fi } +# Point a Hermes-managed Node's `npm install -g` at a directory that is on +# PATH. npm's default global prefix for a bundled Node is the Node dir itself, +# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on +# PATH (only the command link dir is) and is wiped on every Node upgrade. +# Redirecting the prefix to the link dir's parent makes global bins resolve to +# the command link dir (node/npm/npx live there too, already on PATH) and +# survive upgrades. Scoped to the managed Node via its prefix-local global +# npmrc, so the user's other Node installs and their ~/.npmrc are untouched. +# Hermes's own global installs pass an explicit --prefix and are unaffected. +# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on +# every install run repairs pre-existing installs, not just fresh ones. +configure_managed_node_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local link_dir + link_dir="$(get_command_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + get_hermes_command_path() { local link_dir link_dir="$(get_command_link_dir)" @@ -722,6 +741,11 @@ node_satisfies_build() { check_node() { log_info "Checking Node.js (for browser tools)..." + # Repair pre-existing Hermes-managed installs where `npm install -g` lands + # off PATH. No-op when there's no managed Node, so this is safe to run on + # every install — including re-runs that skip the Node (re)install below. + configure_managed_node_npm_prefix + if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then log_success "Node.js $(node --version) found" HAS_NODE=true @@ -851,17 +875,7 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" - # Point this Node's `npm install -g` at a directory that is actually on - # PATH. By default npm's global prefix is the Node install dir, so user - # globals land in $HERMES_HOME/node/bin — which is NOT on PATH (only the - # link dir is) and is wiped on every Node upgrade. Redirecting the prefix - # to the link dir's parent makes global bins land in the link dir - # (node/npm/npx live there too, and it's already on PATH) and survive - # upgrades. Scoped to this Node via its prefix-local global npmrc, so the - # user's other Node installs and their ~/.npmrc are untouched. Hermes's - # own global installs pass an explicit --prefix and are unaffected. - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + configure_managed_node_npm_prefix export PATH="$HERMES_HOME/node/bin:$PATH" diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 15763d70486..332ad81180a 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -57,6 +57,19 @@ _nb_get_link_dir() { fi } +# Redirect a Hermes-managed Node's `npm install -g` to the command link dir +# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off +# PATH and wiped on every Node upgrade. Scoped to the managed Node via its +# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are +# untouched. Idempotent no-op when there's no managed npm. +_nb_configure_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local _link_dir + _link_dir="$(_nb_get_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + _nb_node_major() { local v v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) @@ -207,12 +220,7 @@ _nb_install_bundled_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" - # Redirect this Node's `npm install -g` to the link dir (already on PATH) - # instead of the default $HERMES_HOME/node/bin, which is off PATH and wiped - # on every Node upgrade. Scoped to this Node via its prefix-local global - # npmrc; the user's other Node installs / ~/.npmrc are untouched. - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + _nb_configure_npm_prefix export PATH="$HERMES_HOME/node/bin:$PATH" @@ -228,6 +236,10 @@ _nb_install_bundled_node() { ensure_node() { HERMES_NODE_AVAILABLE=false + # Repair pre-existing managed installs where `npm install -g` lands off + # PATH. No-op when there's no managed Node, so it's safe to run first. + _nb_configure_npm_prefix + if _nb_have_modern_node; then _nb_ok "Node $(node --version) found" HERMES_NODE_AVAILABLE=true diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py index f604fc97d77..e43b9201bd1 100644 --- a/tests/test_install_sh_node_global_prefix.py +++ b/tests/test_install_sh_node_global_prefix.py @@ -25,15 +25,31 @@ def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: # The redirect must target the link dir's PARENT so global bins resolve to # /bin == the command link dir (node/npm/npx live there and it is # guaranteed on PATH by the installer's PATH setup). - assert 'printf \'prefix=%s\\n\' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + assert "configure_managed_node_npm_prefix()" in text + assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - # The npmrc lives under the bundled Node so it only affects this npm, not - # the user's other Node installs or their ~/.npmrc. - assert '"$HERMES_HOME/node/etc/npmrc"' in text + +def test_install_sh_repairs_existing_managed_node_on_rerun() -> None: + """The redirect must run on every install (not just fresh Node installs), + so re-running the installer repairs pre-existing managed installs whose + Node is already up to date and would otherwise skip install_node.""" + text = INSTALL_SH.read_text() + + check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0] + assert "configure_managed_node_npm_prefix" in check_node_body + + # No-op guard so it's safe to call when there is no managed Node. + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: text = NODE_BOOTSTRAP.read_text() + assert "_nb_configure_npm_prefix()" in text assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - assert '"$HERMES_HOME/node/etc/npmrc"' in text + + # Runs at the top of ensure_node so existing managed installs are repaired + # even when a modern Node is already present (early return path). + ensure_node_body = text.split("ensure_node()", 1)[1] + assert "_nb_configure_npm_prefix" in ensure_node_body + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text