fix(install): install npm 12 into the vendored Node 26 tree

The bundled-Node bootstrap unpacked the nodejs.org tarball and stopped.
Node 26.5.1 bundles npm 11.17.0, one minor below the root package.json's
own `engines.npm` floor of >=12 — and .npmrc sets `engine-strict=true`,
so that is fatal rather than a warning:

    npm error code EBADENGINE
    npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
    npm error notsup Actual:   {"node":"v26.5.1","npm":"11.17.0"}

A brand-new install died at the first `npm ci` with "Desktop workspace
npm install failed". CI never saw it because the workflows run an
explicit `npm i -g npm@12`; the Python update path recovers through
hermes_cli/npm_engine.py, but the installer path had no such rung.

_nb_ensure_bundled_npm_range() now upgrades the managed tree's npm into
range right after the tarball lands, mirroring upgrade_managed_npm():

  - temp cwd, so the checkout's own .npmrc (engine-strict,
    min-release-age) does not gate the upgrade meant to satisfy it;
  - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
  - explicit --prefix at the managed tree, because
    _nb_configure_npm_prefix writes prefix=~/.local into its etc/npmrc
    and a bare `npm i -g` would install a second npm elsewhere while the
    managed tree stayed stale.

The range is read out of package.json rather than duplicated, so the two
cannot drift, with HERMES_NPM_TARGET_RANGE as an override and a >=12.0.0
fallback for a stripped install tree. An already-in-range npm skips the
network round-trip. Best-effort: a failed upgrade warns with the manual
command and keeps the working Node, since npm_engine.py still covers the
EBADENGINE that follows.

Verified against a real tree provisioned by this bootstrap: node v26.5.1
/ npm 12.0.2, bin/npm and bin/npx still relative-symlinked into the
upgraded lib/node_modules/npm, the ~/.local/bin links resolving to 12.0.2
through the tree, and no stray second npm under ~/.local/lib.
This commit is contained in:
ethernet
2026-08-01 22:00:26 -04:00
parent 22df1840d6
commit 6fdc64efcd

View File

@@ -76,6 +76,93 @@ _nb_node_major() {
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
}
# The npm range the checkout's root package.json demands. Read from the
# manifest rather than duplicated here so the two can never drift; falls back
# to the current floor when the manifest is unreadable (vendored copy of this
# script, stripped install tree).
_nb_npm_range() {
if [ -n "${HERMES_NPM_TARGET_RANGE:-}" ]; then
printf '%s\n' "$HERMES_NPM_TARGET_RANGE"
return 0
fi
local repo_root manifest range
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
manifest="$repo_root/package.json"
if [ -r "$manifest" ]; then
# sed, not node: this runs before a usable node is guaranteed.
range=$(sed -n '/"engines"/,/}/p' "$manifest" \
| sed -n 's/.*"npm"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -1)
if [ -n "$range" ]; then
printf '%s\n' "$range"
return 0
fi
fi
printf '>=12.0.0\n'
}
# Upgrade the managed tree's bundled npm into the checkout's engines.npm range.
#
# The nodejs.org tarball ships whatever npm that Node major bundles — Node
# 26.5.1 bundles npm 11.17.0, one minor below our own `engines.npm` floor of
# >=12. With `engine-strict=true` in the repo .npmrc that is fatal, not a
# warning, so a brand-new install died at the first `npm ci` with EBADENGINE.
# The Python side recovers through hermes_cli/npm_engine.py; the installer path
# had no such rung, so provision the right npm here instead of reacting later.
#
# Three details are load-bearing, all mirroring upgrade_managed_npm():
# - a temp cwd, so the checkout's own .npmrc (engine-strict, min-release-age)
# does not gate the very upgrade meant to satisfy it;
# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
# - an explicit --prefix at the managed tree, because
# _nb_configure_npm_prefix wrote prefix=~/.local into its etc/npmrc, and
# without the override this installs a second npm elsewhere while the
# managed tree stays stale.
#
# Best-effort: a failure here leaves a working Node with an old npm, which is
# strictly better than no Node at all, and npm_engine.py still covers the
# EBADENGINE that follows.
_nb_ensure_bundled_npm_range() {
local npm_bin="$HERMES_HOME/node/bin/npm"
[ -x "$npm_bin" ] || return 0
local range have want
range="$(_nb_npm_range)"
[ -n "$range" ] || return 0
# Skip the network round-trip when the bundled npm already satisfies the
# range. Only the ">=N" shape we actually author is checked; anything more
# exotic falls through to letting npm itself decide.
if [[ "$range" =~ ^\>=([0-9]+) ]]; then
want="${BASH_REMATCH[1]}"
have=$("$npm_bin" --version 2>/dev/null | cut -d. -f1)
if [[ "$have" =~ ^[0-9]+$ ]] && [ "$have" -ge "$want" ]; then
return 0
fi
fi
_nb_log "Upgrading bundled npm to satisfy $range..."
local tmp_cwd
tmp_cwd=$(mktemp -d)
if (
cd "$tmp_cwd" || exit 1
CI=1 npm_config_min_release_age=0 \
"$npm_bin" install --global \
--prefix "$HERMES_HOME/node" \
"npm@$range" \
--no-fund --no-audit --progress=false >/dev/null 2>&1
); then
rm -rf "$tmp_cwd"
_nb_ok "npm $("$npm_bin" --version 2>/dev/null) installed"
return 0
fi
rm -rf "$tmp_cwd"
_nb_warn "Could not upgrade bundled npm to $range — \`npm ci\` may fail with EBADENGINE."
_nb_warn "Fix manually: npm install -g --prefix \"$HERMES_HOME/node\" npm@\"$range\""
return 1
}
_nb_have_modern_node() {
command -v node >/dev/null 2>&1 || return 1
[ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ]
@@ -232,6 +319,9 @@ _nb_install_bundled_node() {
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) installed to $HERMES_HOME/node/"
# The tarball's bundled npm is usually below the repo's engines.npm floor.
# Best-effort: an old npm still beats no Node.
_nb_ensure_bundled_npm_range || true
return 0
}