fix(update): recover web UI build when tsc/vite missing after npm install

hermes update could report a successful workspace npm ci while
devDependencies were omitted (production/omit-dev env leakage), leaving
tsc/vite unlinked. The subsequent web UI build then failed with
`tsc: not found` and fell back to a stale dashboard dist.

Force npm_config_include=dev on install, verify toolchain shims after
workspace install (and refuse to hash-cache a half tree), repair/reinstall
when node_modules exists without tsc/vite, prepend workspace .bin dirs to
PATH, and retry the build after a tsc/vite-not-found failure.
This commit is contained in:
Gerardo Camorlinga Jr. 2026-07-21 16:47:42 -05:00 committed by Brooklyn Nicholson
parent 2b0fb72aca
commit 363d1aefa4
2 changed files with 243 additions and 11 deletions

View File

@ -5195,6 +5195,15 @@ def _run_npm_install_deterministic(
# --silent/capture_output). It no-ops when CI is set — same as the TUI
# install path and nix/lib.nix npm ci hooks.
run_env = {**os.environ, **(env or {}), "CI": "1"}
# Belt-and-suspenders on top of the CLI ``--include=dev`` flag: an
# inherited ``npm_config_omit=dev`` / ``npm_config_production=true`` from a
# shell profile, container image, or gateway service environment can still
# win depending on npm version + flag ordering, which leaves the install
# green (exit 0) while ``tsc``/``vite`` never land — then ``npm run build``
# dies with ``tsc: not found`` (exit 127). Force the config-level knobs.
run_env["npm_config_include"] = "dev"
run_env["npm_config_production"] = "false"
run_env.pop("npm_config_omit", None)
lockfile = cwd / "package-lock.json"
if lockfile.exists():
@ -5226,6 +5235,49 @@ def _run_npm_install_deterministic(
)
def _node_modules_bin_dir(root: Path) -> Path:
"""Return ``root/node_modules/.bin`` (may not exist yet)."""
return root / "node_modules" / ".bin"
def _npm_bin_exists(bin_dir: Path, name: str) -> bool:
"""True when an npm bin shim for *name* exists (POSIX or Windows)."""
return any(
(bin_dir / candidate).exists()
for candidate in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe")
)
def _web_build_toolchain_ready(project_root: Path) -> bool:
"""Return True when the web UI build toolchain shims are present.
``hermes update`` can report a successful workspace ``npm ci`` while
``tsc``/``vite`` are still missing most often because an inherited
production/omit-dev npm config stripped ``devDependencies`` (exit 0, no
error), or because a prior install was interrupted mid-link. The web
build then fails with ``tsc: not found``. Callers use this as a readiness
gate after install and as a reason to invalidate the lockfile-hash skip.
"""
if not (project_root / "web" / "package.json").is_file():
return True
bin_dir = _node_modules_bin_dir(project_root)
if not bin_dir.is_dir():
return False
return _npm_bin_exists(bin_dir, "tsc") and _npm_bin_exists(bin_dir, "vite")
def _prepend_node_bin_dirs(env: dict[str, str], *roots: Path) -> dict[str, str]:
"""Prepend each root's ``node_modules/.bin`` onto PATH (existing dirs only)."""
merged = dict(env)
parts = [p for p in merged.get("PATH", "").split(os.pathsep) if p]
for root in reversed(roots):
bin_dir = str(_node_modules_bin_dir(root))
if Path(bin_dir).is_dir() and bin_dir not in parts:
parts.insert(0, bin_dir)
merged["PATH"] = os.pathsep.join(parts)
return merged
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available, serializing across processes.
@ -5333,12 +5385,26 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else ("--workspace", "web")
if _is_termux_startup_environment():
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
r1 = _run_npm_install_deterministic(
npm,
npm_cwd,
extra_args=(*npm_workspace_args, "--silent"),
env=build_env,
)
def _install_web_deps(*, silent: bool) -> "subprocess.CompletedProcess":
extra = (*npm_workspace_args,)
if silent:
extra = (*extra, "--silent")
return _run_npm_install_deterministic(
npm,
npm_cwd,
extra_args=extra,
env=build_env,
)
def _refresh_build_path() -> dict[str, str]:
# npm lifecycle scripts normally inject node_modules/.bin, but after a
# partial/hoisted workspace install the web package may have a
# node_modules tree without its own .bin while tsc/vite live at the
# workspace root. Prepending both keeps `tsc -b && vite build` resolvable.
return _prepend_node_bin_dirs(build_env, web_dir, npm_cwd)
r1 = _install_web_deps(silent=True)
if r1.returncode != 0:
_say(
f" {'' if fatal else ''} Web UI npm install failed"
@ -5348,6 +5414,49 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if fatal:
_say(" Run manually: npm install --workspace web && npm run build -w web")
return False
# Install can exit 0 while still omitting devDependencies (production/omit
# config races). Detect missing tsc/vite and force a visible repair install
# before attempting the build — this is the failure mode behind
# ``tsc: not found`` during `hermes update`.
project_root_for_tools = (
web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
)
# Only repair when an install produced a node_modules tree but left the
# toolchain incomplete. Unit tests mock npm with no filesystem side
# effects (no node_modules) — skip the extra install there.
if (
(project_root_for_tools / "node_modules").is_dir()
and not _web_build_toolchain_ready(project_root_for_tools)
):
_say(" ⚠ web toolchain (tsc/vite) missing after npm install — repairing...")
r_repair = _install_web_deps(silent=False)
if r_repair.returncode != 0:
_say(
f" {'' if fatal else ''} Web UI toolchain repair failed"
+ ("" if fatal else " (hermes web will not be available)")
)
_relay(r_repair)
if fatal:
_say(
" Run manually: npm install --workspace web --include=dev "
"&& npm run build -w web"
)
return False
if not _web_build_toolchain_ready(project_root_for_tools):
_say(
f" {'' if fatal else ''} tsc/vite still missing after repair"
+ ("" if fatal else " (hermes web will not be available)")
)
if fatal:
_say(
" Run manually: npm install --workspace web --include=dev "
"&& npm run build -w web"
)
return not fatal
build_env = _refresh_build_path()
# First attempt — stream output via idle-timeout helper (issue #33788).
# capture_output=True on a long Vite build looks identical to a hang;
# users react by rebooting, which leaves the editable install in a
@ -5355,11 +5464,20 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# recoverable (the stale-dist fallback below handles the kill path).
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# Retry once after a short delay — covers boot-time races on Windows
# (antivirus scanning Node.js binaries, npm cache not ready, transient
# I/O when launched via Scheduled Task at logon). See issue #23817.
_time.sleep(3)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
build_out = (r2.stdout or "") + (r2.stderr or "")
# Targeted recovery for the classic missing-devDep failure before the
# generic delayed retry. Re-install (non-silent) + refresh PATH.
if "tsc: not found" in build_out or "vite: not found" in build_out:
_say(" ⚠ Build missing tsc/vite — reinstalling web devDependencies and retrying...")
_install_web_deps(silent=False)
build_env = _refresh_build_path()
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# Retry once after a short delay — covers boot-time races on Windows
# (antivirus scanning Node.js binaries, npm cache not ready, transient
# I/O when launched via Scheduled Task at logon). See issue #23817.
_time.sleep(3)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# _run_with_idle_timeout merges stderr into stdout; older callers
@ -10140,6 +10258,12 @@ def _npm_lockfile_changed(hermes_root: Path) -> bool:
# node_modules means the cache was recorded by another checkout.
if not (PROJECT_ROOT / "node_modules").is_dir():
return True
# A matching lockfile hash with a half-installed tree (e.g. production
# omit stripped tsc/vite) must NOT skip reinstall — otherwise every
# subsequent `hermes update` keeps serving a stale web dist after a
# silent toolchain gap.
if not _web_build_toolchain_ready(PROJECT_ROOT):
return True
try:
# Key the cache by PROJECT_ROOT so parallel worktrees don't collide.
cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12]
@ -10316,6 +10440,18 @@ def _update_node_dependencies() -> list[str]:
env=nixos_env,
)
if ws_result.returncode == 0:
if _web_build_toolchain_ready(PROJECT_ROOT):
_record_npm_lockfile_hash(shared_hermes_root)
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
return []
# Mocked unit tests call this with a successful npm exit code but no
# real node_modules tree — trust the exit code in that case. A real
# install that left node_modules without tsc/vite is the failure mode
# we must not paper over (and must not hash-cache).
if (PROJECT_ROOT / "node_modules").is_dir():
print(" ⚠ npm workspace install finished without tsc/vite shims")
print(" (devDependencies likely omitted — check NODE_ENV / npm omit)")
return _partial_update_failure("ui-tui, web workspaces")
_record_npm_lockfile_hash(shared_hermes_root)
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
return []

View File

@ -563,3 +563,99 @@ class TestBuildWebUIFlock:
def test_lock_file_is_gitignored(self):
gitignore = Path(__file__).resolve().parents[2] / ".gitignore"
assert ".web_ui_build.lock" in gitignore.read_text(encoding="utf-8")
class TestNpmInstallDevDepEnvForcing:
"""Config-level guards so production/omit-dev env can't strip tsc/vite."""
def test_forces_npm_config_include_dev_and_clears_omit(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
_run_npm_install_deterministic(
"/usr/bin/npm",
web_dir,
env={
"NODE_ENV": "production",
"npm_config_omit": "dev",
"npm_config_production": "true",
},
)
_, kwargs = mock_run.call_args
env = kwargs["env"]
assert env["npm_config_include"] == "dev"
assert env["npm_config_production"] == "false"
assert "npm_config_omit" not in env
assert env["CI"] == "1"
class TestWebToolchainReadyAndRepair:
def test_toolchain_ready_requires_tsc_and_vite_shims(self, tmp_path):
from hermes_cli.main import _web_build_toolchain_ready
web_dir, _ = _make_web_dir(tmp_path)
root = web_dir.parent
assert _web_build_toolchain_ready(root) is False
bin_dir = root / "node_modules" / ".bin"
bin_dir.mkdir(parents=True)
(bin_dir / "tsc").touch()
assert _web_build_toolchain_ready(root) is False
(bin_dir / "vite").touch()
assert _web_build_toolchain_ready(root) is True
def test_build_repairs_when_node_modules_exists_without_tsc(self, tmp_path):
"""Partial install (node_modules present, no tsc) triggers a repair install."""
from hermes_cli.main import _resolve_node_runtime_npm
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
# Simulate a production-omit install: tree exists, toolchain missing.
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / ".bin").mkdir()
install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
install_calls = {"n": 0}
def fake_install(*_a, **_k):
install_calls["n"] += 1
# After the repair install, pretend tsc/vite appeared.
if install_calls["n"] >= 2:
bin_dir = tmp_path / "node_modules" / ".bin"
(bin_dir / "tsc").touch()
(bin_dir / "vite").touch()
return install_ok
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", side_effect=fake_install) as mock_install, \
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_ok), \
patch("hermes_cli.main._web_ui_build_needed", return_value=True), \
patch("hermes_cli.main._write_web_ui_build_stamp"):
result = _build_web_ui(web_dir)
assert result is True
# First silent install + one repair install.
assert mock_install.call_count == 2
def test_build_retries_install_on_tsc_not_found(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_fail = __import__("subprocess").CompletedProcess(
[], 127, stdout="sh: 1: tsc: not found\n", stderr=""
)
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._run_with_idle_timeout", side_effect=[build_fail, build_ok]) as mock_build, \
patch("hermes_cli.main._web_ui_build_needed", return_value=True), \
patch("hermes_cli.main._write_web_ui_build_stamp"), \
patch("hermes_cli.main._time.sleep"):
result = _build_web_ui(web_dir)
assert result is True
# Initial install + repair install after tsc: not found.
assert mock_install.call_count == 2
assert mock_build.call_count == 2