fix(install): a failed Node dependency install now fails the install instead of printing success (#85537)

* fix(install): fail when Node dependencies cannot install (#85297)

The POSIX installer converted root and TUI npm failures into warnings, then
printed a dependency-success message and reached the installation-complete
banner with a zero exit status. This left consumers with no usable
node_modules while reporting success.

Treat both required npm installs as fatal: log an error, restore tracked
lockfile churn, return status 1, and propagate the failure from the monolithic
and node-deps stage callers. Successful installs, Termux and missing-Node
skips, missing-manifest skips, and optional Playwright/Browser Use/Computer
Use best-effort behavior remain unchanged. The fix is limited to the POSIX
installer; the PowerShell installer is outside this issue's scope.

Focused and adjacent installer tests passed (32), with bash syntax,
py_compile, and diff checks clean. The broader installer family had 90 passes,
one unrelated pre-existing failure, and two skips; the full suite was
environment-limited by missing dependencies. CodeRabbit, iterative deep
security/compatibility reviews, and final confidence security/compatibility
reviews were clean against the final diff.

Fixes #85297

* fix(install): require npm alongside node in check_node (#77003)

A stray `node` symlink without a sibling `npm` (leftover from a node
version manager) made check_node report "Node.js found"; every later
npm install then failed and the desktop build died with an opaque
"Node.js / npm unavailable". Node now only counts as found when npm
resolves on the same PATH, with an explicit "stray node symlink?" branch
that falls through to the Hermes-managed Node (which bundles npm).

The overlapping success-log honesty half of the original PR is subsumed
by the previous commit, which makes a failed npm install fatal rather
than conditionally-logged; the behavioral tests there cover it, so this
commit keeps only the check_node PATH-gate assertions.

Fixes #77003.

Co-authored-by: criptogus <criptogus@users.noreply.github.com>

---------

Co-authored-by: Eugeniusz Gilewski <egilewski@egilewski.com>
Co-authored-by: CriptoGus <128640021+criptogus@users.noreply.github.com>
Co-authored-by: criptogus <criptogus@users.noreply.github.com>
This commit is contained in:
brooklyn! 2026-08-13 13:39:54 -05:00 committed by GitHub
parent d0bb377c96
commit 6a198f8a12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 219 additions and 12 deletions

View File

@ -835,8 +835,16 @@ check_node() {
# enough for the desktop build AND an npm that can read our .npmrc. A
# bad-band npm (see npm_supports_npmrc) fails `npm ci` outright, and the
# managed Node we install instead bundles one that works.
if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then
if ! command -v npm &> /dev/null || npm_supports_npmrc "$(npm --version 2>/dev/null)"; then
#
# npm must actually be reachable, not just node: a stray `node` symlink
# without a sibling npm (leftover from a node version manager) makes
# `command -v node` succeed while every later `npm install` silently
# fails and the desktop build dies with an opaque "Node.js / npm
# unavailable" (#77003). Node only counts as found when npm resolves on
# the same PATH.
if command -v node &> /dev/null && command -v npm &> /dev/null \
&& node_satisfies_build "$(node --version)"; then
if npm_supports_npmrc "$(npm --version 2>/dev/null)"; then
log_success "Node.js $(node --version) found"
HAS_NODE=true
return 0
@ -848,14 +856,17 @@ check_node() {
fi
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
if [ -x "$HERMES_HOME/node/bin/node" ] && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
if [ -x "$HERMES_HOME/node/bin/node" ] && [ -x "$HERMES_HOME/node/bin/npm" ] \
&& node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
export PATH="$HERMES_HOME/node/bin:$PATH"
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
HAS_NODE=true
return 0
fi
if command -v node &> /dev/null; then
if command -v node &> /dev/null && ! command -v npm &> /dev/null; then
log_warn "node found but npm is not on PATH (stray node symlink?) — installing Hermes-managed Node $NODE_VERSION LTS..."
elif command -v node &> /dev/null; then
log_warn "Node.js $(node --version) is too old (Hermes requires Node >=26) — installing Hermes-managed Node $NODE_VERSION..."
elif [ "$DISTRO" = "termux" ]; then
log_info "Node.js not found — installing Node.js via pkg..."
@ -2294,9 +2305,14 @@ install_node_deps() {
cd "$INSTALL_DIR"
# Time-boxed: a stalled registry fetch would otherwise hang here with no
# progress (same #39219 stall class as the desktop build below).
run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent || {
log_warn "npm install failed or timed out (browser tools may not work)"
}
# A failed npm install used to still print "✓ Node.js dependencies
# installed", hiding the degradation from the user (#77003). Now it
# fails the install outright instead of burying the warning (#85297).
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent; then
log_error "npm install failed or timed out; Node.js dependencies were not installed"
restore_dirty_lockfiles "$INSTALL_DIR"
return 1
fi
log_success "Node.js dependencies installed"
# Install Playwright browser + system dependencies.
@ -2396,9 +2412,13 @@ install_node_deps() {
log_info "Installing TUI dependencies..."
cd "$INSTALL_DIR/ui-tui"
# Time-boxed: a stalled registry fetch would otherwise hang here (#39219).
run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent || {
log_warn "TUI npm install failed or timed out (hermes --tui may not work)"
}
# Report success only on actual success, same as node-deps above
# (#77003) — and fail the install outright (#85297).
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent; then
log_error "TUI npm install failed or timed out; TUI dependencies were not installed"
restore_dirty_lockfiles "$INSTALL_DIR"
return 1
fi
log_success "TUI dependencies installed"
fi
@ -3292,7 +3312,7 @@ run_stage_body() {
resolve_install_layout
require_install_dir
check_node
install_node_deps
install_node_deps || return
install_uv
install_browser_use_cli
install_computer_use_driver
@ -3410,7 +3430,7 @@ main() {
clone_repo
setup_venv
install_deps
install_node_deps
install_node_deps || return
install_browser_use_cli
install_computer_use_driver
setup_path

View File

@ -0,0 +1,145 @@
"""Behavioral coverage for required Node dependency installation."""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
def _write_executable(path: Path, body: str) -> None:
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
def _run_node_deps_stage(
tmp_path: Path,
*,
fail_directory: str | None,
) -> tuple[subprocess.CompletedProcess[str], Path, list[str]]:
install_dir = tmp_path / "install"
tui_dir = install_dir / "ui-tui"
bin_dir = tmp_path / "bin"
hermes_home = tmp_path / "home"
managed_bin = hermes_home / "bin"
npm_calls = tmp_path / "npm-calls"
tui_dir.mkdir(parents=True)
bin_dir.mkdir()
managed_bin.mkdir(parents=True)
(install_dir / "package.json").write_text(
'{"name":"installer-regression-probe","private":true}\n',
encoding="utf-8",
)
(tui_dir / "package.json").write_text(
'{"name":"tui-regression-probe","private":true}\n',
encoding="utf-8",
)
_write_executable(bin_dir / "node", "#!/bin/sh\necho v26.0.0\n")
_write_executable(
bin_dir / "npm",
"""#!/bin/sh
if [ "${1:-}" = "--version" ]; then
echo 12.0.0
exit 0
fi
printf '%s\\n' "$PWD" >> "$NPM_CALLS"
if [ -n "${NPM_FAIL_DIRECTORY:-}" ] && [ "$PWD" = "$NPM_FAIL_DIRECTORY" ]; then
echo "simulated npm lifecycle failure" >&2
exit 37
fi
exit 0
""",
)
_write_executable(managed_bin / "uv", "#!/bin/sh\necho 'uv probe'\n")
env = os.environ.copy()
env.update(
{
"HERMES_HOME": str(hermes_home),
"HERMES_INSTALL_DIR": str(install_dir),
"NPM_CALLS": str(npm_calls),
"NPM_FAIL_DIRECTORY": fail_directory or "",
"PATH": f"{bin_dir}:{env['PATH']}",
}
)
proc = subprocess.run(
[
"bash",
str(INSTALL_SH),
"--stage",
"node-deps",
"--json",
"--skip-browser",
"--skip-computer-use",
],
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
check=False,
)
calls = npm_calls.read_text(encoding="utf-8").splitlines()
return proc, install_dir, calls
def _stage_result(proc: subprocess.CompletedProcess[str]) -> dict[str, object]:
return json.loads(proc.stdout.splitlines()[-1])
def test_root_node_dependency_failure_is_fatal(tmp_path: Path) -> None:
install_dir = tmp_path / "install"
proc, actual_install_dir, calls = _run_node_deps_stage(
tmp_path,
fail_directory=str(install_dir),
)
assert actual_install_dir == install_dir
assert proc.returncode != 0
assert _stage_result(proc) == {
"ok": False,
"stage": "node-deps",
"skipped": False,
"reason": "exit code 1",
}
assert calls == [str(install_dir)]
assert "Node.js dependencies installed" not in proc.stdout
assert "TUI dependencies installed" not in proc.stdout
assert not (install_dir / "node_modules").exists()
def test_tui_node_dependency_failure_is_fatal(tmp_path: Path) -> None:
install_dir = tmp_path / "install"
tui_dir = install_dir / "ui-tui"
proc, _, calls = _run_node_deps_stage(
tmp_path,
fail_directory=str(tui_dir),
)
assert proc.returncode != 0
assert _stage_result(proc)["ok"] is False
assert calls == [str(install_dir), str(tui_dir)]
assert "Node.js dependencies installed" in proc.stdout
assert "TUI dependencies installed" not in proc.stdout
def test_node_dependency_success_remains_successful(tmp_path: Path) -> None:
proc, install_dir, calls = _run_node_deps_stage(
tmp_path,
fail_directory=None,
)
assert proc.returncode == 0, proc.stderr
assert _stage_result(proc) == {
"ok": True,
"stage": "node-deps",
"skipped": False,
}
assert calls == [str(install_dir), str(install_dir / "ui-tui")]
assert "Node.js dependencies installed" in proc.stdout
assert "TUI dependencies installed" in proc.stdout

View File

@ -0,0 +1,42 @@
"""Regression tests for install.sh Node/npm checks (#77003).
A stray `node` symlink without a sibling `npm` (leftover from a node
version manager) made the installer report "✓ Node.js found" and then fail
opaquely at the desktop stage. Node must only count as found when npm
resolves on the same PATH, and npm install stages must not report success
when the install actually failed.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
def test_check_node_requires_npm_alongside_node() -> None:
"""check_node must not report success when only `node` resolves.
Before the fix, `command -v node` succeeding was enough a stray node
symlink (no sibling npm) passed the check, every later `npm install`
failed silently, and the desktop build died with an opaque
"Node.js / npm unavailable" (#77003).
"""
text = INSTALL_SH.read_text()
# The system-toolchain branch now gates on BOTH node and npm.
assert (
"if command -v node &> /dev/null && command -v npm &> /dev/null \\" in text
)
# The "node found but npm missing" case has its own explicit branch that
# falls through to installing the Hermes-managed Node (which bundles npm).
assert "node found but npm is not on PATH (stray node symlink?)" in text
def test_check_node_managed_requires_npm() -> None:
"""The Hermes-managed Node fallback also requires its npm to exist."""
text = INSTALL_SH.read_text()
assert (
'[ -x "$HERMES_HOME/node/bin/node" ] && [ -x "$HERMES_HOME/node/bin/npm" ] \\'
in text
)