Desktop build: electronDist path and binary issues
NousResearch/hermes-agent · cluster 46785 · apps/bootstrap-installer
Data as of
Nothing open on GitHub. Fix merged in #47276. Check it covers your case.
The short answer
- Known?
- Yes. 13 related items grouped here, 9 issues already closed.
- Fix event
- A fix was merged to main in PR #47276. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #47276, #48081, #48091. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple bugs and proposed fixes concern the desktop build encountering an unresolvable electronDist path, stemming from Electron binary placement mismatches between workspace-local and repo-root node_modules, unreliable downloads, and installer misdiagnosis of related errors. Members span macOS, Windows, and Linux.
Open issues0
Open PRs0
Fixes on record
-
#47276 fix(desktop): re-download Electron binary via mirror when pack fails (#47266)
files:
hermes_cli/main.py,scripts/install.ps1,scripts/install.sh,tests/hermes_cli/test_gui_command.pyShow the diff
-
hermes_cli/main.py@@ -5110,6 +5110,90 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]: return removed +def _electron_dist_binary(project_root: Path) -> Path: + """Return the path to the Electron main binary inside ``node_modules``. + + electron-builder reads the binary from ``build.electronDist`` + (``node_modules/electron/dist``) since #38673, so this is the exact file + whose absence makes a pack fail with "The specified electronDist does not + exist". The basename differs per OS (the platform Electron is named for the + host the build runs on). + """ + dist = project_root / "node_modules" / "electron" / "dist" + if sys.platform == "darwin": + return dist / "Electron.app" / "Contents" / "MacOS" / "Electron" + if sys.platform == "win32": + return dist / "electron.exe" + return dist / "electron" + + +def _electron_dist_ok(project_root: Path) -> bool: + """True when ``node_modules/electron/dist`` holds a usable Electron binary. + + A directory that exists but is missing the binary (a partial extraction from + a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since + that is exactly the shape that makes electron-builder throw on the pinned + electronDist. + """ + try: + return _electron_dist_binary(project_root).exists() + except OSError: + return False + + +def _redownload_electron_dist( + project_root: Path, + env: dict, + *, + mirror: Optional[str] = None, +) -> bool: + """(Re)populate ``node_modules/electron/dist`` via electron's own downloader. + + Since #38673 the desktop build pins ``build.electronDist`` to + ``node_modules/electron/dist``, so electron-builder reads the Electron binary + straight from there and never downloads it during ``npm run pack``. That dist + tree is produced by the ``electron`` package's postinstall (``install.js``) + during ``npm ci``. When that download is blocked or throttled (GitHub's + release host is unreachable in some regions — #47266), the dist is missing + and re-running ``pack`` only re-throws "The specified electronDist does not + exist". The mirror fallback therefore has to drive *this* downloader, not + another ``pack``. + + No-op (returns True) when the dist binary is already present, so an unrelated + build failure doesn't trigger a needless ~200 MB re-download. Otherwise drops + any partial dist + version marker (electron's install.js short-circuits when + ``path.txt`` already matches) and runs the downloader once, optionally via a + mirror. Best-effort: never raises. Returns True iff the dist binary exists + afterward. + """ + if _electron_dist_ok(project_root): + return True + + electron_dir = project_root / "node_modules" / "electron" + installer = electron_dir / "install.js" + if not installer.is_file(): + return False + node = shutil.which("node") + if not node: + return False + + dist_dir = electron_dir / "dist" + shutil.rmtree(dist_dir, ignore_errors=True) + try: + (electron_dir / "path.txt").unlink() + except OSError: + pass + + dl_env = dict(env) + if mirror: + dl_env["ELECTRON_MIRROR"] = mirror + try: + subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False) + except OSError: + return False + return _electron_dist_ok(project_root) + + def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]: """Terminate any running desktop app executing from this build's ``release`` dir so a rebuild can replace its (otherwise locked) executable. @@ -5364,8 +5448,18 @@ def cmd_gui(args: argparse.Namespace): # failure was something else, the clean re-download is harmless # and the retry fails the same way. purged = _purge_electron_build_cache(desktop_dir) - if purged: - print(" ⚠ Desktop build failed; cleared cached Electron download and retrying once...") + # electronDist is pinned to node_modules/electron/dist (#38673): + # electron-builder reads the Electron binary from there and `pack` + # never downloads it, so purging the cache + re-running pack can't + # by itself repopulate a missing/partial dist. When the dist is + # actually gone, re-run electron's own downloader so the retry has + # a binary to read. Gated on the dist check so an unrelated build + # failure (tsc/vite) doesn't trigger a pointless ~200 MB refetch. + restored = False + if not _electron_dist_ok(PROJECT_ROOT): + restored = _redownload_electron_dist(PROJECT_ROOT, env) + if purged or restored: + print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...") for p in purged: print(f" - {p}") # The purge can't remove a win-unpacked tree whose Hermes.exe @@ -5383,12 +5477,25 @@ def cmd_gui(args: argparse.Namespace): # trade-off we only make AFTER the canonical GitHub download has # failed, and we never override a user-pinned ELECTRON_MIRROR. print(" ⚠ Desktop build still failing; the Electron download from " - "GitHub looks blocked. Retrying once via a public mirror " + "GitHub looks blocked. Re-downloading via a public mirror " "(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)") + mirror = "https://npmmirror.com/mirrors/electron/" mirror_env = dict(env) - mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/" - _stop_desktop_processes_locking_build(desktop_dir) - build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False) + mirror_env["ELECTRON_MIRROR"] = mirror + # electronDist is pinned (#38673), so `npm run pack` never + # downloads Electron — the mirror only helps if it drives + # electron's own downloader. Re-fetch the binary through the + # mirror first; otherwise the retry just re-reads the same missing + # dist and re-throws "electronDist does not exist" (#47266). + have_dist = _electron_dist_ok(PROJECT_ROOT) + if not have_dist: + have_dist = _redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror) + if have_dist: + _stop_desktop_processes_locking_build(desktop_dir) + build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False) + else: + print(" ✗ Could not re-download Electron from the mirror " + "(node_modules/electron/dist still missing)") if build_result.returncode != 0: print("✗ Desktop GUI build failed") print(f" Run manually: cd apps/desktop && npm run {build_script}") -
scripts/install.ps1@@ -2161,6 +2161,66 @@ function Clear-ElectronBuildCache { return $removed } +# True when node_modules\electron\dist holds a usable Electron binary. +# electron-builder reads the binary from build.electronDist +# (node_modules\electron\dist) since #38673, so this is the exact file whose +# absence makes a pack fail with "The specified electronDist does not exist". A +# dist dir that exists but is missing electron.exe (partial extraction / aborted +# postinstall) is NOT ok. +function Test-ElectronDist { + param([string]$InstallDir) + $distExe = Join-Path $InstallDir 'node_modules\electron\dist\electron.exe' + return (Test-Path -LiteralPath $distExe) +} + +# (Re)populate node_modules\electron\dist via electron's own downloader. +# +# Since #38673 the desktop build pins build.electronDist to +# node_modules\electron\dist, so electron-builder reads the Electron binary +# straight from there and never downloads it during `npm run pack`. That dist +# tree is produced by the electron package's postinstall (install.js) during +# `npm ci`. When that download is blocked/throttled (GitHub's release host is +# unreachable in some regions - #47266), dist is missing and re-running pack only +# re-throws "The specified electronDist does not exist". The mirror fallback +# therefore has to drive THIS downloader, not another pack. +# +# No-op (returns $true) when the dist binary is already present. Otherwise drops a +# partial dist + version marker (electron's install.js short-circuits when +# path.txt already matches) and runs the downloader once, optionally via a +# mirror. Best-effort: never throws. Returns $true iff the dist binary exists +# afterward. +function Restore-ElectronDist { + param([string]$InstallDir, [string]$Mirror) + if (Test-ElectronDist -InstallDir $InstallDir) { return $true } + + $electronDir = Join-Path $InstallDir 'node_modules\electron' + $distExe = Join-Path $electronDir 'dist\electron.exe' + $installer = Join-Path $electronDir 'install.js' + if (-not (Test-Path -LiteralPath $installer)) { return $false } + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { return $false } + + $distDir = Join-Path $electronDir 'dist' + if (Test-Path -LiteralPath $distDir) { + Remove-Item -LiteralPath $distDir -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath (Join-Path $electronDir 'path.txt') -Force -ErrorAction SilentlyContinue + + $prevMirror = $env:ELECTRON_MIRROR + if ($Mirror) { $env:ELECTRON_MIRROR = $Mirror } + try { + # Out-Host so the downloader's progress shows on the console WITHOUT + # leaking into this function's return value (PowerShell returns every + # object left on the output stream, so a bare pipe here would make the + # boolean below ambiguous). + & $node.Source $installer 2>&1 | ForEach-Object { "$_" } | Out-Host + } catch { + } finally { + $env:ELECTRON_MIRROR = $prevMirror + } + return (Test-Path -LiteralPath $distExe) +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -2310,8 +2370,19 @@ function Install-Desktop { # once; @electron/get re-downloads with its own SHASUM check. Without # this a corrupt download hard-fails the whole installer. $purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir) - if ($purged.Count -gt 0) { - Write-Warn "Desktop build failed - cleared cached Electron download, retrying once:" + # electronDist is pinned to node_modules\electron\dist (#38673): + # electron-builder reads the Electron binary from there and `pack` + # never downloads it, so purging the cache + re-running pack can't by + # itself repopulate a missing/partial dist. When the dist is actually + # gone, re-run electron's own downloader so the retry has a binary to + # read. Gated on the dist check so an unrelated build failure + # (tsc/vite) doesn't trigger a pointless ~200MB refetch. + $restored = $false + if (-not (Test-ElectronDist -InstallDir $InstallDir)) { + $restored = Restore-ElectronDist -InstallDir $InstallDir + } + if ($purged.Count -gt 0 -or $restored) { + Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:" foreach ($p in $purged) { Write-Info " - $p" } & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog $code = $LASTEXITCODE @@ -2326,14 +2397,23 @@ function Install-Desktop { # trade-off we only make AFTER the canonical GitHub download has failed, # and we never override a user-pinned ELECTRON_MIRROR. if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) { - $prevMirror = $env:ELECTRON_MIRROR - $env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/" + $mirror = "https://npmmirror.com/mirrors/electron/" Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked." - Write-Warn "Retrying once via a public Electron mirror ($($env:ELECTRON_MIRROR)):" + Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:" Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog - $code = $LASTEXITCODE - $env:ELECTRON_MIRROR = $prevMirror + # electronDist is pinned (#38673), so `npm run pack` never downloads + # Electron - the mirror only helps if it drives electron's own + # downloader. Re-fetch the binary through the mirror first; otherwise + # the retry just re-reads the same missing dist and re-throws + # "The specified electronDist does not exist" (#47266). + $haveDist = Test-ElectronDist -InstallDir $InstallDir + if (-not $haveDist) { $haveDist = Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror } + if ($haveDist) { + & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog + $code = $LASTEXITCODE + } else { + Write-Warn "Could not re-download Electron from the mirror (node_modules\electron\dist still missing)" + } } $ErrorActionPreference = $prevEAP if ($code -ne 0) { -
scripts/install.sh@@ -2407,6 +2407,58 @@ _desktop_pack() { # failed, and we never override a user-pinned ELECTRON_MIRROR. DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/" +# True (returns 0) when node_modules/electron/dist holds a usable Electron +# binary. electron-builder reads the binary from build.electronDist +# (node_modules/electron/dist) since #38673, so this is the exact file whose +# absence makes a pack fail with "The specified electronDist does not exist". A +# dist dir that exists but is missing the binary (partial extraction / aborted +# postinstall) is NOT ok. $1 = the workspace root holding node_modules. +_electron_dist_ok() { + local install_dir="$1" + local electron_dir="$install_dir/node_modules/electron" + if [ "$OS" = "macos" ]; then + [ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ] + else + [ -e "$electron_dir/dist/electron" ] + fi +} + +# (Re)populate node_modules/electron/dist via electron's own downloader. +# +# Since #38673 the desktop build pins build.electronDist to +# node_modules/electron/dist, so electron-builder reads the Electron binary +# straight from there and never downloads it during `npm run pack`. That dist +# tree is produced by the electron package's postinstall (install.js) during +# `npm ci`. When that download is blocked/throttled (GitHub's release host is +# unreachable in some regions - #47266), dist is missing and re-running pack only +# re-throws "The specified electronDist does not exist". The mirror fallback +# therefore has to drive THIS downloader, not another pack. +# +# No-op (returns 0) when the dist binary is already present. Otherwise drops a +# partial dist + version marker (electron's install.js short-circuits when +# path.txt already matches) and runs the downloader once. $1 = the workspace root +# holding node_modules; optional $2 = an ELECTRON_MIRROR base URL. Best-effort: +# returns 0 iff the dist binary exists afterward. +_restore_electron_dist() { + local install_dir="$1" + local mirror="${2:-}" + local electron_dir="$install_dir/node_modules/electron" + _electron_dist_ok "$install_dir" && return 0 + + [ -f "$electron_dir/install.js" ] || return 1 + command -v node >/dev/null 2>&1 || return 1 + + rm -rf "$electron_dir/dist" 2>/dev/null || true + rm -f "$electron_dir/path.txt" 2>/dev/null || true + + if [ -n "$mirror" ]; then + ( cd "$electron_dir" && ELECTRON_MIRROR="$mirror" node install.js ) || true + else + ( cd "$electron_dir" && node install.js ) || true + fi + _electron_dist_ok "$install_dir" +} + # Build apps/desktop into a launchable native app. Mirrors install.ps1's # Install-Desktop: a root-level npm install so the apps/* workspace resolves # the desktop's own deps (Electron ~150MB), then `npm run pack` @@ -2479,8 +2531,19 @@ install_desktop() { # (b) Corrupt cached Electron zip is the most common self-healable cause. local purged purged="$(clear_electron_build_cache "$desktop_dir")" - if [ -n "$purged" ]; then - log_warn "Desktop build failed; cleared cached Electron download and retrying once..." + # electronDist is pinned to node_modules/electron/dist (#38673): + # electron-builder reads the binary from there and `pack` never downloads + # it, so purging the cache + re-running pack can't by itself repopulate a + # missing/partial dist. When the dist is actually gone, re-run electron's + # own downloader so the retry has a binary to read. Gated on the dist + # check so an unrelated build failure (tsc/vite) doesn't trigger a + # pointless ~200MB refetch. + local restored=false + if ! _electron_dist_ok "$INSTALL_DIR"; then + if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi + fi + if [ -n "$purged" ] || [ "$restored" = true ]; then + log_warn "Desktop build failed; refreshed the Electron download and retrying once..." if _desktop_pack "$desktop_dir"; then pack_ok=true fi @@ -2488,14 +2551,26 @@ install_desktop() { fi # (c) Still failing and the user hasn't pinned their own mirror: the GitHub - # release host is likely blocked/throttled. Retry once via a public - # Electron mirror (@electron/get still SHASUM-verifies the download). + # release host is likely blocked/throttled. Re-download the Electron + # binary via a public mirror, then retry. The mirror MUST drive + # electron's own downloader — `npm run pack` reads the pinned electronDist + # and never downloads, so a mirror passed only to pack is a no-op (#47266). if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then log_warn "Desktop build still failing — the Electron download from GitHub looks blocked." - log_warn "Retrying once via a public Electron mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR)..." + log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..." log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then - pack_ok=true + local have_dist=false + if _electron_dist_ok "$INSTALL_DIR"; then + have_dist=true + elif _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then + have_dist=true + fi + if [ "$have_dist" = true ]; then + if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then + pack_ok=true + fi + else + log_warn "Could not re-download Electron from the mirror (node_modules/electron/dist still missing)" fi fi -
tests/hermes_cli/test_gui_command.py@@ -498,9 +498,10 @@ def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch): assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)] -def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch, capsys): - """Purge clears nothing (not a cache problem) → fall back to an Electron - mirror once before failing, so a GitHub-blocked download self-heals.""" +def test_gui_redownloads_electron_via_mirror_then_repacks(tmp_path, monkeypatch, capsys): + """Purge clears nothing and the pinned electronDist (#38673) is missing → + the mirror fallback must drive electron's own downloader (NOT another pack, + which never downloads Electron) and only then retry pack (#47266).""" root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch, platform="linux") @@ -512,21 +513,59 @@ def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \ + patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ + patch("hermes_cli.main._electron_dist_ok", return_value=False), \ + patch("hermes_cli.main._redownload_electron_dist", side_effect=[False, True]) as mock_dl, \ patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 1 - mock_purge.assert_called_once() - # pack(fail) → purge(nothing) → pack via mirror(fail) = 2 subprocess.run calls + # initial pack + mirror pack = 2 npm calls. The first-retry pack is skipped + # because the canonical-source re-download (no mirror) failed, so there was + # never a binary to build against. assert mock_run.call_count == 2 - # The retry runs the same build but with ELECTRON_MIRROR injected. + # First re-download attempt is canonical (no mirror); the second drives the + # public mirror. + assert mock_dl.call_args_list[0].kwargs.get("mirror") is None + assert mock_dl.call_args_list[1].kwargs["mirror"] + # Only the mirror-driven pack carries ELECTRON_MIRROR. assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {}) assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"] assert "Desktop GUI build failed" in capsys.readouterr().out +def test_gui_skips_pack_when_electron_redownload_unrecoverable(tmp_path, monkeypatch, capsys): + """When the Electron binary can't be fetched at all (mirror also blocked), + skip the pointless final pack — it would just re-throw the same missing + electronDist — and fail with a clear message instead.""" + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch, platform="linux") + monkeypatch.delenv("ELECTRON_MIRROR", raising=False) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ + patch("hermes_cli.main._electron_dist_ok", return_value=False), \ + patch("hermes_cli.main._redownload_electron_dist", return_value=False), \ + patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + # Only the initial pack ran; both retries were skipped because no binary + # could be produced. + assert mock_run.call_count == 1 + out = capsys.readouterr().out + assert "Could not re-download Electron from the mirror" in out + assert "Desktop GUI build failed" in out + + def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys): """A user-pinned ELECTRON_MIRROR is respected: no extra mirror fallback attempt (and we never swap in our default mirror).""" @@ -553,6 +592,108 @@ def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsy assert "Desktop GUI build failed" in capsys.readouterr().out +# ── electronDist (re)download helper tests (#47266) ─────────────────── + + [redacted:email]( + "platform,rel", + [ + ("linux", "dist/electron"), + ("win32", "dist/electron.exe"), + ("darwin", "dist/Electron.app/Contents/MacOS/Electron"), + ], +) +def test_electron_dist_ok_per_platform(tmp_path, monkeypatch, platform, rel): + monkeypatch.setattr(cli_main.sys, "platform", platform) + electron = tmp_path / "node_modules" / "electron" + # A dist dir that exists but lacks the binary is NOT ok (partial extraction). + (electron / "dist").mkdir(parents=True) + assert cli_main._electron_dist_ok(tmp_path) is False + + binp = electron / rel + binp.parent.mkdir(parents=True, exist_ok=True) + binp.write_text("", encoding="utf-8") + assert cli_main._electron_dist_ok(tmp_path) is True + + +def test_redownload_electron_dist_noop_when_present(tmp_path, monkeypatch): + """Already-healthy dist → no download, so an unrelated build failure can't + trigger a needless ~200 MB refetch.""" + monkeypatch.setattr(cli_main.sys, "platform", "linux") + binp = tmp_path / "node_modules" / "electron" / "dist" / "electron" + binp.parent.mkdir(parents=True) + binp.write_text("", encoding="utf-8") + + with patch("hermes_cli.main.subprocess.run") as mock_run: + assert cli_main._redownload_electron_dist(tmp_path, {}) is True + mock_run.assert_not_called() + + +def test_redownload_electron_dist_missing_installer(tmp_path, monkeypatch): + """No electron/install.js (deps never installed) → nothing to run.""" + monkeypatch.setattr(cli_main.sys, "platform", "linux") + (tmp_path / "node_modules" / "electron").mkdir(parents=True) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/node"), \ + patch("hermes_cli.main.subprocess.run") as mock_run: + assert cli_main._redownload_electron_dist(tmp_path, {}) is False + mock_run.assert_not_called() + + +def test_redownload_electron_dist_runs_installer_with_mirror(tmp_path, monkeypatch): + """Missing dist → wipe any partial dist + version marker, run electron's own + install.js with ELECTRON_MIRROR injected, and report success on the binary.""" + monkeypatch.setattr(cli_main.sys, "platform", "linux") + electron = tmp_path / "node_modules" / "electron" + electron.mkdir(parents=True) + (electron / "install.js").write_text("// stub", encoding="utf-8") + # A stale partial dist + version marker that MUST be cleared first, otherwise + # electron's install.js short-circuits on path.txt and never re-downloads. + (electron / "dist").mkdir() + (electron / "dist" / "leftover").write_text("junk", encoding="utf-8") + (electron / "path.txt").write_text("electron", encoding="utf-8") + + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs.get("env") + captured["cwd"] = kwargs.get("cwd") + # simulate electron's install.js producing the dist binary + binp = electron / "dist" / "electron" + binp.parent.mkdir(parents=True, exist_ok=True) + binp.write_text("", encoding="utf-8") + return subprocess.CompletedProcess(cmd, 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/node"), \ + patch("hermes_cli.main.subprocess.run", side_effect=fake_run): + ok = cli_main._redownload_electron_dist( + tmp_path, {"PATH": "/x"}, mirror="https://mirror.example/electron/" + ) + + assert ok is True + assert captured["cmd"] == ["/usr/bin/node", str(electron / "install.js")] + assert captured["cwd"] == str(electron) + assert captured["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/" + # The partial dir + marker were dropped before the re-download. + assert not (electron / "dist" / "leftover").exists() + assert not (electron / "path.txt").exists() + + +def test_redownload_electron_dist_returns_false_when_download_fails(tmp_path, monkeypatch): + """install.js ran but produced no binary (still blocked) → False, so the + caller skips a doomed pack.""" + monkeypatch.setattr(cli_main.sys, "platform", "linux") + electron = tmp_path / "node_modules" / "electron" + electron.mkdir(parents=True) + (electron / "install.js").write_text("// stub", encoding="utf-8") + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/node"), \ + patch("hermes_cli.main.subprocess.run", + return_value=subprocess.CompletedProcess(["node"], 1)): + assert cli_main._redownload_electron_dist(tmp_path, {}) is False + + class _FakeProc: """Minimal psutil.Process stand-in for the lock-breaker tests."""
-
-
#48081 fix(desktop): resolve electronDist to the actual electron install location
files:
apps/desktop/package.json,apps/desktop/scripts/patch-electron-builder-mac-binary.cjs,hermes_cli/main.py,scripts/install.ps1,scripts/install.sh,scripts/release.py,tests/hermes_cli/test_gui_command.py,tests/test_desktop_electron_pin.pyShow the diff
-
apps/desktop/package.json@@ -135,7 +135,7 @@ }, "build": { "electronVersion": "40.10.2", - "electronDist": "../../node_modules/electron/dist", + "electronDist": "node_modules/electron/dist", "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", -
apps/desktop/scripts/patch-electron-builder-mac-binary.cjs@@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop if (!fs.existsSync(bundledElectronBinary)) { const candidates = [ path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName), + // npm may nest the workspace-only electron devDep under + // apps/desktop/node_modules (process.cwd() during pack), or hoist + // it to the repo root. Try the workspace-local install first, then + // the root hoist, so the fallback works under either layout. + path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName), path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName), ]; const sourceBinary = candidates.find(candidate => fs.existsSync(candidate)); -
hermes_cli/main.py@@ -5110,16 +5110,33 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]: return removed +def _electron_dir(project_root: Path) -> Path: + """Return the Electron package directory the desktop workspace installs. + + npm may keep workspace-only dev dependencies under + ``apps/desktop/node_modules`` instead of hoisting them to the repo root. + Which layout you get depends on the npm version and what else is installed, + so a build path that assumes one or the other breaks intermittently across + machines. ``apps/desktop/package.json`` points electron-builder's + ``electronDist`` at ``node_modules/electron/dist`` relative to the desktop + project, so prefer the workspace-local package and fall back to the root + hoist when that's where npm landed it. + """ + desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron" + if desktop_local.exists(): + return desktop_local + return project_root / "node_modules" / "electron" + + def _electron_dist_binary(project_root: Path) -> Path: - """Return the path to the Electron main binary inside ``node_modules``. + """Return the path to the Electron main binary inside the installed package. - electron-builder reads the binary from ``build.electronDist`` - (``node_modules/electron/dist``) since #38673, so this is the exact file - whose absence makes a pack fail with "The specified electronDist does not - exist". The basename differs per OS (the platform Electron is named for the - host the build runs on). + electron-builder reads the binary from ``build.electronDist`` since #38673, + so this is the exact file whose absence makes a pack fail with "The + specified electronDist does not exist". The basename differs per OS (the + platform Electron is named for the host the build runs on). """ - dist = project_root / "node_modules" / "electron" / "dist" + dist = _electron_dir(project_root) / "dist" if sys.platform == "darwin": return dist / "Electron.app" / "Contents" / "MacOS" / "Electron" if sys.platform == "win32": @@ -5169,7 +5186,7 @@ def _redownload_electron_dist( if _electron_dist_ok(project_root): return True - electron_dir = project_root / "node_modules" / "electron" + electron_dir = _electron_dir(project_root) installer = electron_dir / "install.js" if not installer.is_file(): return False @@ -5387,8 +5404,8 @@ def cmd_gui(args: argparse.Namespace): print(" Pre-build first: cd apps/desktop && npm run build") print(" Or drop --skip-build to install dependencies and build automatically.") sys.exit(1) - if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists(): - print("✗ --skip-build --source requires existing workspace dependencies.") + if not (_electron_dir(PROJECT_ROOT) / "package.json").exists(): + print("✗ --skip-build --source requires existing desktop workspace dependencies.") print(f" Install first: cd {PROJECT_ROOT} && npm ci") print(" Or drop --skip-build to install dependencies and build automatically.") sys.exit(1) -
scripts/install.ps1@@ -2161,28 +2161,41 @@ function Clear-ElectronBuildCache { return $removed } -# True when node_modules\electron\dist holds a usable Electron binary. -# electron-builder reads the binary from build.electronDist -# (node_modules\electron\dist) since #38673, so this is the exact file whose -# absence makes a pack fail with "The specified electronDist does not exist". A -# dist dir that exists but is missing electron.exe (partial extraction / aborted -# postinstall) is NOT ok. +# Return the Electron package directory the desktop workspace installs. npm may +# nest workspace-only dev dependencies under apps\desktop\node_modules instead +# of hoisting them to the repo root; which layout you get depends on the npm +# version and what else is installed. apps\desktop\package.json points +# electron-builder's electronDist there, so prefer the workspace-local package +# and fall back to the root hoist. +function Get-ElectronDir { + param([string]$InstallDir) + $desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron' + if (Test-Path -LiteralPath $desktopLocal) { return $desktopLocal } + return (Join-Path $InstallDir 'node_modules\electron') +} + +# True when the desktop workspace electronDist holds a usable Electron binary. +# electron-builder reads the binary from build.electronDist since #38673, so +# this is the exact file whose absence makes a pack fail with "The specified +# electronDist does not exist". A dist dir that exists but is missing +# electron.exe (partial extraction / aborted postinstall) is NOT ok. function Test-ElectronDist { param([string]$InstallDir) - $distExe = Join-Path $InstallDir 'node_modules\electron\dist\electron.exe' + $electronDir = Get-ElectronDir -InstallDir $InstallDir + $distExe = Join-Path $electronDir 'dist\electron.exe' return (Test-Path -LiteralPath $distExe) } -# (Re)populate node_modules\electron\dist via electron's own downloader. +# (Re)populate the desktop Electron dist via electron's own downloader. # -# Since #38673 the desktop build pins build.electronDist to -# node_modules\electron\dist, so electron-builder reads the Electron binary -# straight from there and never downloads it during `npm run pack`. That dist -# tree is produced by the electron package's postinstall (install.js) during -# `npm ci`. When that download is blocked/throttled (GitHub's release host is -# unreachable in some regions - #47266), dist is missing and re-running pack only -# re-throws "The specified electronDist does not exist". The mirror fallback -# therefore has to drive THIS downloader, not another pack. +# Since #38673 the desktop build pins build.electronDist, so electron-builder +# reads the Electron binary straight from there and never downloads it during +# `npm run pack`. That dist tree is produced by the electron package's +# postinstall (install.js) during `npm ci`. When that download is +# blocked/throttled (GitHub's release host is unreachable in some regions - +# #47266), dist is missing and re-running pack only re-throws "The specified +# electronDist does not exist". The mirror fallback therefore has to drive THIS +# downloader, not another pack. # # No-op (returns $true) when the dist binary is already present. Otherwise drops a # partial dist + version marker (electron's install.js short-circuits when @@ -2193,7 +2206,7 @@ function Restore-ElectronDist { param([string]$InstallDir, [string]$Mirror) if (Test-ElectronDist -InstallDir $InstallDir) { return $true } - $electronDir = Join-Path $InstallDir 'node_modules\electron' + $electronDir = Get-ElectronDir -InstallDir $InstallDir $distExe = Join-Path $electronDir 'dist\electron.exe' $installer = Join-Path $electronDir 'install.js' if (-not (Test-Path -LiteralPath $installer)) { return $false } -
scripts/install.sh@@ -2407,15 +2407,31 @@ _desktop_pack() { # failed, and we never override a user-pinned ELECTRON_MIRROR. DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/" -# True (returns 0) when node_modules/electron/dist holds a usable Electron -# binary. electron-builder reads the binary from build.electronDist -# (node_modules/electron/dist) since #38673, so this is the exact file whose -# absence makes a pack fail with "The specified electronDist does not exist". A -# dist dir that exists but is missing the binary (partial extraction / aborted -# postinstall) is NOT ok. $1 = the workspace root holding node_modules. +# Return the Electron package directory the desktop workspace installs. npm may +# nest workspace-only dev dependencies under apps/desktop/node_modules instead +# of hoisting them to the repo root; which layout you get depends on the npm +# version and what else is installed. apps/desktop/package.json points +# electron-builder's electronDist there, so prefer the workspace-local package +# and fall back to the root hoist. $1 = the workspace root holding node_modules. +_electron_dir() { + local install_dir="$1" + if [ -d "$install_dir/apps/desktop/node_modules/electron" ]; then + printf '%s\n' "$install_dir/apps/desktop/node_modules/electron" + else + printf '%s\n' "$install_dir/node_modules/electron" + fi +} + +# True (returns 0) when the desktop workspace electronDist holds a usable +# Electron binary. electron-builder reads the binary from build.electronDist +# since #38673, so this is the exact file whose absence makes a pack fail with +# "The specified electronDist does not exist". A dist dir that exists but is +# missing the binary (partial extraction / aborted postinstall) is NOT ok. +# $1 = the workspace root holding node_modules. _electron_dist_ok() { local install_dir="$1" - local electron_dir="$install_dir/node_modules/electron" + local electron_dir + electron_dir="$(_electron_dir "$install_dir")" if [ "$OS" = "macos" ]; then [ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ] else @@ -2423,16 +2439,16 @@ _electron_dist_ok() { fi } -# (Re)populate node_modules/electron/dist via electron's own downloader. +# (Re)populate the desktop Electron dist via electron's own downloader. # -# Since #38673 the desktop build pins build.electronDist to -# node_modules/electron/dist, so electron-builder reads the Electron binary -# straight from there and never downloads it during `npm run pack`. That dist -# tree is produced by the electron package's postinstall (install.js) during -# `npm ci`. When that download is blocked/throttled (GitHub's release host is -# unreachable in some regions - #47266), dist is missing and re-running pack only -# re-throws "The specified electronDist does not exist". The mirror fallback -# therefore has to drive THIS downloader, not another pack. +# Since #38673 the desktop build pins build.electronDist, so electron-builder +# reads the Electron binary straight from there and never downloads it during +# `npm run pack`. That dist tree is produced by the electron package's +# postinstall (install.js) during `npm ci`. When that download is +# blocked/throttled (GitHub's release host is unreachable in some regions - +# #47266), dist is missing and re-running pack only re-throws "The specified +# electronDist does not exist". The mirror fallback therefore has to drive THIS +# downloader, not another pack. # # No-op (returns 0) when the dist binary is already present. Otherwise drops a # partial dist + version marker (electron's install.js short-circuits when @@ -2442,7 +2458,8 @@ _electron_dist_ok() { _restore_electron_dist() { local install_dir="$1" local mirror="${2:-}" - local electron_dir="$install_dir/node_modules/electron" + local electron_dir + electron_dir="$(_electron_dir "$install_dir")" _electron_dist_ok "$install_dir" && return 0 [ -f "$electron_dir/install.js" ] || return 1 -
scripts/release.py@@ -45,6 +45,9 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "sitkarev", + "[redacted:email]": "omegazheng", + "[redacted:email]": "james47kjv", "[redacted:email]": "1960697431", "[redacted:email]": "definitelynotguru", "[redacted:email]": "ChasLui", -
tests/hermes_cli/test_gui_command.py@@ -616,6 +616,33 @@ def test_electron_dist_ok_per_platform(tmp_path, monkeypatch, platform, rel): assert cli_main._electron_dist_ok(tmp_path) is True +def test_electron_dir_prefers_workspace_local_package(tmp_path): + """npm may nest electron under apps/desktop; resolve there over the root hoist.""" + root_electron = tmp_path / "node_modules" / "electron" + local_electron = tmp_path / "apps" / "desktop" / "node_modules" / "electron" + root_electron.mkdir(parents=True) + local_electron.mkdir(parents=True) + + assert cli_main._electron_dir(tmp_path) == local_electron + + +def test_electron_dir_falls_back_to_root_hoist(tmp_path): + """When npm hoists electron to the repo root, resolve there.""" + root_electron = tmp_path / "node_modules" / "electron" + root_electron.mkdir(parents=True) + + assert cli_main._electron_dir(tmp_path) == root_electron + + +def test_electron_dist_ok_finds_workspace_local_binary(tmp_path, monkeypatch): + """A nested apps/desktop electron with a valid binary counts as ok.""" + monkeypatch.setattr(cli_main.sys, "platform", "linux") + binp = tmp_path / "apps" / "desktop" / "node_modules" / "electron" / "dist" / "electron" + binp.parent.mkdir(parents=True) + binp.write_text("", encoding="utf-8") + assert cli_main._electron_dist_ok(tmp_path) is True + + def test_redownload_electron_dist_noop_when_present(tmp_path, monkeypatch): """Already-healthy dist → no download, so an unrelated build failure can't trigger a needless ~200 MB refetch.""" -
tests/test_desktop_electron_pin.py@@ -94,3 +94,42 @@ def test_lockfile_resolves_the_pinned_electron(): f"but the pin is {spec!r}; run `npm install --package-lock-only` so " "`npm ci` stays consistent." ) + + +def test_electron_dist_matches_lockfile_install_location(): + """build.electronDist must point at where the lockfile installs Electron. + + electron-builder copies the unpacked Electron from ``build.electronDist`` + (resolved relative to ``apps/desktop``). npm workspace hoisting is not + deterministic across machines/npm versions: it may nest Electron under + ``apps/desktop/node_modules/electron`` or hoist it to the repo root. If + electronDist points at one location while the lockfile installs at the + other, packaging fails with ``The specified electronDist does not exist`` — + the "Building desktop app" failure reported after the June lockfile + regeneration floated Electron and reshuffled the hoist. Lock the two + together so a hoist change (root <-> nested) can't silently break the path + again. + """ + if not ROOT_LOCK.is_file(): + pytest.skip("root package-lock.json not present") + electron_dist = _desktop_pkg().get("build", {}).get("electronDist") + assert electron_dist, "build.electronDist is missing" + + lock = json.loads(ROOT_LOCK.read_text(encoding="utf-8")) + electron_paths = [ + path + for path in lock.get("packages", {}) + if path.endswith("node_modules/electron") + ] + assert electron_paths, "no electron entry found in package-lock.json" + + desktop_dir = REPO_ROOT / "apps" / "desktop" + # electronDist is resolved relative to the apps/desktop project dir. + configured = (desktop_dir / electron_dist).resolve() + # Where the lockfile actually places Electron's unpacked dist. + installed = {(REPO_ROOT / p / "dist").resolve() for p in electron_paths} + assert configured in installed, ( + f"build.electronDist={electron_dist!r} resolves to {configured}, but the " + f"lockfile installs Electron at {sorted(str(p) for p in installed)}. " + "electron-builder will fail with 'electronDist does not exist'." + )
-
-
#48091 fix(desktop): resolve electronDist dynamically + self-heal blocked installs (supersedes #48081/#48082)
files:
apps/desktop/package.json,apps/desktop/scripts/run-electron-builder.cjs,hermes_cli/main.py,scripts/install.ps1,scripts/install.sh,tests/hermes_cli/test_gui_command.py,tests/test_desktop_electron_pin.pyShow the diff
-
apps/desktop/package.json@@ -21,7 +21,7 @@ "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", "postbuild": "node scripts/assert-dist-built.cjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", - "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", + "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", "dist:mac": "npm run build && npm run builder -- --mac", @@ -135,7 +135,6 @@ }, "build": { "electronVersion": "40.10.2", - "electronDist": "node_modules/electron/dist", "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", -
apps/desktop/scripts/run-electron-builder.cjs@@ -0,0 +1,57 @@ +"use strict" + +// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can +// re-unpack a broken Electron.app; reusing the installed dist dodges that. +// npm workspace hoisting is non-deterministic — require.resolve finds electron +// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let +// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR). + +const fs = require("node:fs") +const path = require("node:path") +const { spawnSync } = require("node:child_process") + +function electronDistDir() { + try { + return path.join(path.dirname(require.resolve("electron/package.json")), "dist") + } catch { + return null + } +} + +function distBinary(dist) { + if (process.platform === "darwin") { + return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron") + } + if (process.platform === "win32") { + return path.join(dist, "electron.exe") + } + return path.join(dist, "electron") +} + +function electronBuilderCli() { + const pkgJson = require.resolve("electron-builder/package.json") + const bin = require(pkgJson).bin + const rel = typeof bin === "string" ? bin : bin["electron-builder"] + return path.join(path.dirname(pkgJson), rel) +} + +const dist = electronDistDir() +const args = [] +if (dist && fs.existsSync(distBinary(dist))) { + args.push(`-c.electronDist=${dist}`) +} else { + console.warn( + "[run-electron-builder] no local electron dist; electron-builder will fetch " + + "via @electron/get (electronVersion + ELECTRON_MIRROR)." + ) +} +args.push(...process.argv.slice(2)) + +const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], { + stdio: "inherit", +}) +if (result.error) { + console.error(`[run-electron-builder] spawn failed: ${result.error.message}`) + process.exit(1) +} +process.exit(result.status == null ? 1 : result.status) -
hermes_cli/main.py@@ -5110,6 +5110,11 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]: return removed +# Last-resort Electron mirror after GitHub download fails (#47266). Only used +# when the user hasn't pinned ELECTRON_MIRROR. +_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/" + + def _electron_dir(project_root: Path) -> Path: """Return the Electron package directory the desktop workspace installs. @@ -5158,31 +5163,23 @@ def _electron_dist_ok(project_root: Path) -> bool: return False +def _electron_pkg_staged_missing_dist(project_root: Path) -> bool: + """electron staged (package.json + install.js) but dist missing — blocked postinstall.""" + electron_dir = _electron_dir(project_root) + return ( + (electron_dir / "package.json").is_file() + and (electron_dir / "install.js").is_file() + and not _electron_dist_ok(project_root) + ) + + def _redownload_electron_dist( project_root: Path, env: dict, *, mirror: Optional[str] = None, ) -> bool: - """(Re)populate ``node_modules/electron/dist`` via electron's own downloader. - - Since #38673 the desktop build pins ``build.electronDist`` to - ``node_modules/electron/dist``, so electron-builder reads the Electron binary - straight from there and never downloads it during ``npm run pack``. That dist - tree is produced by the ``electron`` package's postinstall (``install.js``) - during ``npm ci``. When that download is blocked or throttled (GitHub's - release host is unreachable in some regions — #47266), the dist is missing - and re-running ``pack`` only re-throws "The specified electronDist does not - exist". The mirror fallback therefore has to drive *this* downloader, not - another ``pack``. - - No-op (returns True) when the dist binary is already present, so an unrelated - build failure doesn't trigger a needless ~200 MB re-download. Otherwise drops - any partial dist + version marker (electron's install.js short-circuits when - ``path.txt`` already matches) and runs the downloader once, optionally via a - mirror. Best-effort: never raises. Returns True iff the dist binary exists - afterward. - """ + """Best-effort: run electron's install.js to populate dist/ (optional mirror).""" if _electron_dist_ok(project_root): return True @@ -5211,6 +5208,15 @@ def _redownload_electron_dist( return _electron_dist_ok(project_root) +def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool: + """Canonical download, then fallback mirror unless the user pinned one.""" + if _redownload_electron_dist(project_root, env): + return True + if env.get("ELECTRON_MIRROR"): + return False + return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR) + + def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]: """Terminate any running desktop app executing from this build's ``release`` dir so a rebuild can replace its (otherwise locked) executable. @@ -5433,9 +5439,18 @@ def cmd_gui(args: argparse.Namespace): nixos_env = _nixos_build_env() install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env) if install_result.returncode != 0: - print("✗ Desktop dependency install failed") - print(f" Run manually: cd {PROJECT_ROOT} && npm ci") - sys.exit(install_result.returncode or 1) + if not _electron_pkg_staged_missing_dist(PROJECT_ROOT): + print("✗ Desktop dependency install failed") + print(f" Run manually: cd {PROJECT_ROOT} && npm ci") + sys.exit(install_result.returncode or 1) + repaired = _try_redownload_electron_dist(PROJECT_ROOT, env) + if repaired: + print(" ⚠ Dependency install failed with a missing Electron dist; " + "repopulated it and continuing.") + else: + print(" ⚠ Dependency install failed with a missing Electron dist; " + "continuing to the build so electron-builder can attempt " + "the Electron fetch itself.") build_label = "source build" if source_mode else "packaged app" print(f"→ Building desktop {build_label}...") @@ -5451,31 +5466,15 @@ def cmd_gui(args: argparse.Namespace): print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})") build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False) if build_result.returncode != 0 and not source_mode: - # A corrupt cached Electron zip makes `pack` fail with an ENOENT - # on the final `electron` -> `Hermes` rename: unpack-electron - # extracted a partial tree (missing the 193 MB binary) from the - # bad zip. We do NOT try to prove the zip is corrupt ourselves — - # stdlib zipfile silently tolerates the prepended/concatenated - # junk that is the most common corruption (a partial download - # resumed into the same file), so a `testzip()` gate would pass - # and never self-heal. Instead, on any packaged-build failure we - # purge the version's cached zip + the half-written unpacked dir - # and retry once: @electron/get re-downloads with its own SHASUM - # verification, which is the real source of truth. If the - # failure was something else, the clean re-download is harmless - # and the retry fails the same way. - purged = _purge_electron_build_cache(desktop_dir) - # electronDist is pinned to node_modules/electron/dist (#38673): - # electron-builder reads the Electron binary from there and `pack` - # never downloads it, so purging the cache + re-running pack can't - # by itself repopulate a missing/partial dist. When the dist is - # actually gone, re-run electron's own downloader so the retry has - # a binary to read. Gated on the dist check so an unrelated build - # failure (tsc/vite) doesn't trigger a pointless ~200 MB refetch. + # Corrupt cached Electron zip → partial unpack → ENOENT on rename. + # stdlib zipfile won't catch the common concat-junk case, so purge + # and retry once; @electron/get SHASUM is the real gate. + purged: list[Path] = [] restored = False if not _electron_dist_ok(PROJECT_ROOT): + purged = _purge_electron_build_cache(desktop_dir) restored = _redownload_electron_dist(PROJECT_ROOT, env) - if purged or restored: + if restored: print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...") for p in purged: print(f" - {p}") @@ -5484,35 +5483,16 @@ def cmd_gui(args: argparse.Namespace): _stop_desktop_processes_locking_build(desktop_dir) build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False) if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"): - # Still failing and the user hasn't pinned a mirror: GitHub's - # Electron release host is likely blocked/throttled (the repeating - # "retrying" download log). Retry once via npmmirror.com — the - # de-facto Electron community mirror (Alibaba). @electron/get - # SHASUM-checks the download, but the SHASUMS come from the same - # mirror, so that guards against a corrupt/partial download, NOT - # a compromised mirror: reaching for it is an explicit trust - # trade-off we only make AFTER the canonical GitHub download has - # failed, and we never override a user-pinned ELECTRON_MIRROR. print(" ⚠ Desktop build still failing; the Electron download from " "GitHub looks blocked. Re-downloading via a public mirror " "(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)") - mirror = "https://npmmirror.com/mirrors/electron/" + mirror = _ELECTRON_FALLBACK_MIRROR mirror_env = dict(env) mirror_env["ELECTRON_MIRROR"] = mirror - # electronDist is pinned (#38673), so `npm run pack` never - # downloads Electron — the mirror only helps if it drives - # electron's own downloader. Re-fetch the binary through the - # mirror first; otherwise the retry just re-reads the same missing - # dist and re-throws "electronDist does not exist" (#47266). - have_dist = _electron_dist_ok(PROJECT_ROOT) - if not have_dist: - have_dist = _redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror) - if have_dist: - _stop_desktop_processes_locking_build(desktop_dir) - build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False) - else: - print(" ✗ Could not re-download Electron from the mirror " - "(node_modules/electron/dist still missing)") + if not _electron_dist_ok(PROJECT_ROOT): + _redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror) + _stop_desktop_processes_locking_build(desktop_dir) + build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False) if build_result.returncode != 0: print("✗ Desktop GUI build failed") print(f" Run manually: cd apps/desktop && npm run {build_script}") -
scripts/install.ps1@@ -2161,12 +2161,10 @@ function Clear-ElectronBuildCache { return $removed } -# Return the Electron package directory the desktop workspace installs. npm may -# nest workspace-only dev dependencies under apps\desktop\node_modules instead -# of hoisting them to the repo root; which layout you get depends on the npm -# version and what else is installed. apps\desktop\package.json points -# electron-builder's electronDist there, so prefer the workspace-local package -# and fall back to the root hoist. +# Last-resort Electron mirror after GitHub download fails (#47266). +$script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/" + +# Electron package dir — workspace-local nest first, then root hoist. function Get-ElectronDir { param([string]$InstallDir) $desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron' @@ -2174,11 +2172,7 @@ function Get-ElectronDir { return (Join-Path $InstallDir 'node_modules\electron') } -# True when the desktop workspace electronDist holds a usable Electron binary. -# electron-builder reads the binary from build.electronDist since #38673, so -# this is the exact file whose absence makes a pack fail with "The specified -# electronDist does not exist". A dist dir that exists but is missing -# electron.exe (partial extraction / aborted postinstall) is NOT ok. +# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs). function Test-ElectronDist { param([string]$InstallDir) $electronDir = Get-ElectronDir -InstallDir $InstallDir @@ -2186,22 +2180,7 @@ function Test-ElectronDist { return (Test-Path -LiteralPath $distExe) } -# (Re)populate the desktop Electron dist via electron's own downloader. -# -# Since #38673 the desktop build pins build.electronDist, so electron-builder -# reads the Electron binary straight from there and never downloads it during -# `npm run pack`. That dist tree is produced by the electron package's -# postinstall (install.js) during `npm ci`. When that download is -# blocked/throttled (GitHub's release host is unreachable in some regions - -# #47266), dist is missing and re-running pack only re-throws "The specified -# electronDist does not exist". The mirror fallback therefore has to drive THIS -# downloader, not another pack. -# -# No-op (returns $true) when the dist binary is already present. Otherwise drops a -# partial dist + version marker (electron's install.js short-circuits when -# path.txt already matches) and runs the downloader once, optionally via a -# mirror. Best-effort: never throws. Returns $true iff the dist binary exists -# afterward. +# Best-effort: run electron/install.js to populate dist/ (optional mirror). function Restore-ElectronDist { param([string]$InstallDir, [string]$Mirror) if (Test-ElectronDist -InstallDir $InstallDir) { return $true } @@ -2234,6 +2213,23 @@ function Restore-ElectronDist { return (Test-Path -LiteralPath $distExe) } +function Test-ElectronPkgStagedMissingDist { + param([string]$InstallDir) + $electronDir = Get-ElectronDir -InstallDir $InstallDir + return ( + (Test-Path -LiteralPath (Join-Path $electronDir 'package.json')) -and + (Test-Path -LiteralPath (Join-Path $electronDir 'install.js')) -and + (-not (Test-ElectronDist -InstallDir $InstallDir)) + ) +} + +function Try-RestoreElectronDist { + param([string]$InstallDir) + if (Restore-ElectronDist -InstallDir $InstallDir) { return $true } + if ($env:ELECTRON_MIRROR) { return $false } + return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -2329,10 +2325,16 @@ function Install-Desktop { } $ErrorActionPreference = $prevEAP if ($code -ne 0) { - Show-NpmCertHint ($npmOut -join "`n") | Out-Null - throw "desktop workspace npm install failed (exit $code) -- see lines above for cause" + if (Test-ElectronPkgStagedMissingDist -InstallDir $InstallDir) { + Write-Warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..." + Try-RestoreElectronDist -InstallDir $InstallDir | Out-Null + } else { + Show-NpmCertHint ($npmOut -join "`n") | Out-Null + throw "desktop workspace npm install failed (exit $code) -- see lines above for cause" + } + } else { + Write-Success "Desktop workspace dependencies installed" } - Write-Success "Desktop workspace dependencies installed" } catch { if ($prevEAP) { $ErrorActionPreference = $prevEAP } Pop-Location @@ -2375,57 +2377,34 @@ function Install-Desktop { & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog $code = $LASTEXITCODE if ($code -ne 0) { - # A corrupt cached Electron zip makes `pack` fail with an opaque - # ENOENT on the final `electron` -> `Hermes` rename: app-builder's - # unpack-electron extracted a partial tree (missing the binary) from - # the bad zip, and re-running reuses the poisoned cache forever. - # Purge the cached download + any stale unpacked output and retry - # once; @electron/get re-downloads with its own SHASUM check. Without - # this a corrupt download hard-fails the whole installer. - $purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir) - # electronDist is pinned to node_modules\electron\dist (#38673): - # electron-builder reads the Electron binary from there and `pack` - # never downloads it, so purging the cache + re-running pack can't by - # itself repopulate a missing/partial dist. When the dist is actually - # gone, re-run electron's own downloader so the retry has a binary to - # read. Gated on the dist check so an unrelated build failure - # (tsc/vite) doesn't trigger a pointless ~200MB refetch. + $purged = @() $restored = $false if (-not (Test-ElectronDist -InstallDir $InstallDir)) { + $purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir) $restored = Restore-ElectronDist -InstallDir $InstallDir } - if ($purged.Count -gt 0 -or $restored) { + if ($restored) { Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:" foreach ($p in $purged) { Write-Info " - $p" } & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog $code = $LASTEXITCODE } } - # Still failing and the user hasn't pinned their own mirror: GitHub's - # Electron release host is likely blocked/throttled (the repeating - # "retrying" log). Retry once via npmmirror.com — the de-facto Electron - # community mirror (Alibaba). @electron/get SHASUM-checks the download, - # but the SHASUMS come from the same mirror, so that guards against a - # corrupt/partial download, NOT a compromised mirror: an explicit trust - # trade-off we only make AFTER the canonical GitHub download has failed, - # and we never override a user-pinned ELECTRON_MIRROR. if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) { - $mirror = "https://npmmirror.com/mirrors/electron/" + $mirror = $script:DesktopElectronFallbackMirror Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked." Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:" Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - # electronDist is pinned (#38673), so `npm run pack` never downloads - # Electron - the mirror only helps if it drives electron's own - # downloader. Re-fetch the binary through the mirror first; otherwise - # the retry just re-reads the same missing dist and re-throws - # "The specified electronDist does not exist" (#47266). - $haveDist = Test-ElectronDist -InstallDir $InstallDir - if (-not $haveDist) { $haveDist = Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror } - if ($haveDist) { + if (-not (Test-ElectronDist -InstallDir $InstallDir)) { + Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror | Out-Null + } + $prevMirror = $env:ELECTRON_MIRROR + $env:ELECTRON_MIRROR = $mirror + try { & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog $code = $LASTEXITCODE - } else { - Write-Warn "Could not re-download Electron from the mirror (node_modules\electron\dist still missing)" + } finally { + $env:ELECTRON_MIRROR = $prevMirror } } $ErrorActionPreference = $prevEAP -
scripts/install.sh@@ -2398,21 +2398,10 @@ _desktop_pack() { fi } -# Public Electron mirror used as a last-resort fallback when GitHub's release -# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is -# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks -# the download, but the SHASUMS come from the same mirror — that guards against a -# corrupt/partial download, NOT a compromised mirror. Reaching for it is an -# explicit trust trade-off we only make AFTER the canonical GitHub download has -# failed, and we never override a user-pinned ELECTRON_MIRROR. +# Last-resort Electron mirror after GitHub download fails (#47266). DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/" -# Return the Electron package directory the desktop workspace installs. npm may -# nest workspace-only dev dependencies under apps/desktop/node_modules instead -# of hoisting them to the repo root; which layout you get depends on the npm -# version and what else is installed. apps/desktop/package.json points -# electron-builder's electronDist there, so prefer the workspace-local package -# and fall back to the root hoist. $1 = the workspace root holding node_modules. +# Electron package dir — workspace-local nest first, then root hoist. _electron_dir() { local install_dir="$1" if [ -d "$install_dir/apps/desktop/node_modules/electron" ]; then @@ -2422,12 +2411,7 @@ _electron_dir() { fi } -# True (returns 0) when the desktop workspace electronDist holds a usable -# Electron binary. electron-builder reads the binary from build.electronDist -# since #38673, so this is the exact file whose absence makes a pack fail with -# "The specified electronDist does not exist". A dist dir that exists but is -# missing the binary (partial extraction / aborted postinstall) is NOT ok. -# $1 = the workspace root holding node_modules. +# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs). _electron_dist_ok() { local install_dir="$1" local electron_dir @@ -2439,22 +2423,7 @@ _electron_dist_ok() { fi } -# (Re)populate the desktop Electron dist via electron's own downloader. -# -# Since #38673 the desktop build pins build.electronDist, so electron-builder -# reads the Electron binary straight from there and never downloads it during -# `npm run pack`. That dist tree is produced by the electron package's -# postinstall (install.js) during `npm ci`. When that download is -# blocked/throttled (GitHub's release host is unreachable in some regions - -# #47266), dist is missing and re-running pack only re-throws "The specified -# electronDist does not exist". The mirror fallback therefore has to drive THIS -# downloader, not another pack. -# -# No-op (returns 0) when the dist binary is already present. Otherwise drops a -# partial dist + version marker (electron's install.js short-circuits when -# path.txt already matches) and runs the downloader once. $1 = the workspace root -# holding node_modules; optional $2 = an ELECTRON_MIRROR base URL. Best-effort: -# returns 0 iff the dist binary exists afterward. +# Best-effort: run electron/install.js to populate dist/ (optional mirror). _restore_electron_dist() { local install_dir="$1" local mirror="${2:-}" @@ -2476,6 +2445,19 @@ _restore_electron_dist() { _electron_dist_ok "$install_dir" } +_electron_pkg_staged_missing_dist() { + local install_dir="$1" + local electron_dir + electron_dir="$(_electron_dir "$install_dir")" + [ -f "$electron_dir/package.json" ] && [ -f "$electron_dir/install.js" ] && ! _electron_dist_ok "$install_dir" +} + +_restore_electron_dist_with_fallback() { + local install_dir="$1" + _restore_electron_dist "$install_dir" \ + || { [ -z "${ELECTRON_MIRROR:-}" ] && _restore_electron_dist "$install_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; } +} + # Build apps/desktop into a launchable native app. Mirrors install.ps1's # Install-Desktop: a root-level npm install so the apps/* workspace resolves # the desktop's own deps (Electron ~150MB), then `npm run pack` @@ -2517,7 +2499,12 @@ install_desktop() { # `tsc -b` failing with no obvious cause. Fall back to `npm install` # only if `npm ci` is unavailable or the lockfile is out of sync. log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..." - ( cd "$INSTALL_DIR" && npm ci ) || ( cd "$INSTALL_DIR" && npm install ) || { + if ( cd "$INSTALL_DIR" && npm ci ) || ( cd "$INSTALL_DIR" && npm install ); then + log_success "Desktop workspace dependencies installed" + elif _electron_pkg_staged_missing_dist "$INSTALL_DIR"; then + log_warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..." + _restore_electron_dist_with_fallback "$INSTALL_DIR" || true + else log_error "Desktop workspace npm install failed" # Common cause: a previous 'sudo npm'/'sudo npx' left root-owned files in # ~/.npm, so this non-root install can't write the shared cache. npm hides @@ -2530,8 +2517,7 @@ install_desktop() { log_info "Then re-run this installer, or build manually:" log_info " cd \"$INSTALL_DIR\" && npm ci && cd apps/desktop && npm run pack" return 1 - } - log_success "Desktop workspace dependencies installed" + fi # 2. Build, with up to three escalating attempts so a transient/blocked # Electron download self-heals instead of failing the whole install: @@ -2545,21 +2531,13 @@ install_desktop() { if _desktop_pack "$desktop_dir"; then pack_ok=true else - # (b) Corrupt cached Electron zip is the most common self-healable cause. - local purged - purged="$(clear_electron_build_cache "$desktop_dir")" - # electronDist is pinned to node_modules/electron/dist (#38673): - # electron-builder reads the binary from there and `pack` never downloads - # it, so purging the cache + re-running pack can't by itself repopulate a - # missing/partial dist. When the dist is actually gone, re-run electron's - # own downloader so the retry has a binary to read. Gated on the dist - # check so an unrelated build failure (tsc/vite) doesn't trigger a - # pointless ~200MB refetch. + local purged="" local restored=false if ! _electron_dist_ok "$INSTALL_DIR"; then + purged="$(clear_electron_build_cache "$desktop_dir")" if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi fi - if [ -n "$purged" ] || [ "$restored" = true ]; then + if [ "$restored" = true ]; then log_warn "Desktop build failed; refreshed the Electron download and retrying once..." if _desktop_pack "$desktop_dir"; then pack_ok=true @@ -2567,27 +2545,14 @@ install_desktop() { fi fi - # (c) Still failing and the user hasn't pinned their own mirror: the GitHub - # release host is likely blocked/throttled. Re-download the Electron - # binary via a public mirror, then retry. The mirror MUST drive - # electron's own downloader — `npm run pack` reads the pinned electronDist - # and never downloads, so a mirror passed only to pack is a no-op (#47266). + # (c) GitHub blocked → mirror fallback (#47266). if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then log_warn "Desktop build still failing — the Electron download from GitHub looks blocked." log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..." log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - local have_dist=false - if _electron_dist_ok "$INSTALL_DIR"; then - have_dist=true - elif _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then - have_dist=true - fi - if [ "$have_dist" = true ]; then - if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then - pack_ok=true - fi - else - log_warn "Could not re-download Electron from the mirror (node_modules/electron/dist still missing)" + _electron_dist_ok "$INSTALL_DIR" || _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR" || true + if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then + pack_ok=true fi fi -
tests/hermes_cli/test_gui_command.py@@ -485,13 +485,15 @@ def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch): patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main._write_desktop_build_stamp"), \ patch("hermes_cli.main._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \ + patch("hermes_cli.main._electron_dist_ok", return_value=False), \ + patch("hermes_cli.main._redownload_electron_dist", return_value=True), \ patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_ok, launch_ok]) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 0 mock_purge.assert_called_once() - # pack(fail) → purge → pack(ok) → launch = 3 subprocess.run calls + # pack(fail) → repair succeeds → pack(ok) → launch = 3 subprocess.run calls assert mock_run.call_count == 3 assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"] assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "run", "pack"] @@ -535,10 +537,12 @@ def test_gui_redownloads_electron_via_mirror_then_repacks(tmp_path, monkeypatch, assert "Desktop GUI build failed" in capsys.readouterr().out -def test_gui_skips_pack_when_electron_redownload_unrecoverable(tmp_path, monkeypatch, capsys): - """When the Electron binary can't be fetched at all (mirror also blocked), - skip the pointless final pack — it would just re-throw the same missing - electronDist — and fail with a clear message instead.""" +def test_gui_retries_pack_under_mirror_even_when_prefetch_blocked(tmp_path, monkeypatch, capsys): + """When electron's own downloader can't fetch the binary (even via the + mirror), still retry pack under ELECTRON_MIRROR: the build resolves + electronDist dynamically and lets electron-builder fetch Electron itself + via @electron/get, which honors the mirror. That retry is no longer + pointless (it was, back when electronDist was a static path).""" root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch, platform="linux") @@ -553,17 +557,96 @@ def test_gui_skips_pack_when_electron_redownload_unrecoverable(tmp_path, monkeyp patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ patch("hermes_cli.main._electron_dist_ok", return_value=False), \ patch("hermes_cli.main._redownload_electron_dist", return_value=False), \ - patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail]) as mock_run, \ + patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 1 - # Only the initial pack ran; both retries were skipped because no binary - # could be produced. - assert mock_run.call_count == 1 - out = capsys.readouterr().out - assert "Could not re-download Electron from the mirror" in out - assert "Desktop GUI build failed" in out + # Initial pack + mirror-driven pack = 2; the mirror retry runs even though + # the pre-fetch failed, so electron-builder gets a shot at downloading. + assert mock_run.call_count == 2 + assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {}) + assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"] + assert "Desktop GUI build failed" in capsys.readouterr().out + + +def test_gui_install_failure_self_heals_electron_and_continues(tmp_path, monkeypatch, capsys): + """npm ci failing on electron's blocked binary download must NOT abort the + install: with the electron package staged, repopulate its dist and continue + to the build instead of sys.exit-ing before pack ever runs (#47266/#48021).""" + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux") + # electron package staged on disk (postinstall download was the casualty). + (root / "apps" / "desktop" / "node_modules" / "electron").mkdir(parents=True) + (root / "apps" / "desktop" / "node_modules" / "electron" / "package.json").write_text("{}", encoding="utf-8") + (root / "apps" / "desktop" / "node_modules" / "electron" / "install.js").write_text("", encoding="utf-8") + + install_fail = subprocess.CompletedProcess(["npm", "ci"], 1) + pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_fail), \ + patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main._electron_dist_ok", return_value=False), \ + patch("hermes_cli.main._try_redownload_electron_dist", return_value=True) as mock_dl, \ + patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 0 + mock_dl.assert_called() # tried to repopulate the dist + # pack + launch ran — the install failure did NOT abort the build. + assert mock_run.call_count == 2 + assert "repopulated" in capsys.readouterr().out.lower() + + +def test_gui_install_failure_hard_fails_when_electron_not_staged(tmp_path, monkeypatch, capsys): + """A dependency-install failure where electron never even staged is a genuine + error (not a blocked binary download) — hard-fail with guidance, don't try to + self-heal a tree that isn't there.""" + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch, platform="linux") + + install_fail = subprocess.CompletedProcess(["npm", "ci"], 1) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_fail), \ + patch("hermes_cli.main.subprocess.run") as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + mock_run.assert_not_called() # build never started + assert "Desktop dependency install failed" in capsys.readouterr().out + + +def test_gui_install_failure_hard_fails_when_electron_dist_exists(tmp_path, monkeypatch, capsys): + """If npm install fails but Electron dist is already present, don't classify + it as the blocked-download shape; fail fast as a generic install error.""" + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch, platform="linux") + electron_dir = root / "apps" / "desktop" / "node_modules" / "electron" + electron_dir.mkdir(parents=True) + (electron_dir / "package.json").write_text("{}", encoding="utf-8") + (electron_dir / "install.js").write_text("", encoding="utf-8") + + install_fail = subprocess.CompletedProcess(["npm", "ci"], 1) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_fail), \ + patch("hermes_cli.main._electron_dist_ok", return_value=True), \ + patch("hermes_cli.main.subprocess.run") as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + mock_run.assert_not_called() + assert "Desktop dependency install failed" in capsys.readouterr().out def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys): -
tests/test_desktop_electron_pin.py@@ -96,40 +96,40 @@ def test_lockfile_resolves_the_pinned_electron(): ) -def test_electron_dist_matches_lockfile_install_location(): - """build.electronDist must point at where the lockfile installs Electron. - - electron-builder copies the unpacked Electron from ``build.electronDist`` - (resolved relative to ``apps/desktop``). npm workspace hoisting is not - deterministic across machines/npm versions: it may nest Electron under - ``apps/desktop/node_modules/electron`` or hoist it to the repo root. If - electronDist points at one location while the lockfile installs at the - other, packaging fails with ``The specified electronDist does not exist`` — - the "Building desktop app" failure reported after the June lockfile - regeneration floated Electron and reshuffled the hoist. Lock the two - together so a hoist change (root <-> nested) can't silently break the path - again. - """ - if not ROOT_LOCK.is_file(): - pytest.skip("root package-lock.json not present") - electron_dist = _desktop_pkg().get("build", {}).get("electronDist") - assert electron_dist, "build.electronDist is missing" +DESKTOP_DIR = REPO_ROOT / "apps" / "desktop" +ELECTRON_BUILDER_WRAPPER = DESKTOP_DIR / "scripts" / "run-electron-builder.cjs" - lock = json.loads(ROOT_LOCK.read_text(encoding="utf-8")) - electron_paths = [ - path - for path in lock.get("packages", {}) - if path.endswith("node_modules/electron") - ] - assert electron_paths, "no electron entry found in package-lock.json" - - desktop_dir = REPO_ROOT / "apps" / "desktop" - # electronDist is resolved relative to the apps/desktop project dir. - configured = (desktop_dir / electron_dist).resolve() - # Where the lockfile actually places Electron's unpacked dist. - installed = {(REPO_ROOT / p / "dist").resolve() for p in electron_paths} - assert configured in installed, ( - f"build.electronDist={electron_dist!r} resolves to {configured}, but the " - f"lockfile installs Electron at {sorted(str(p) for p in installed)}. " - "electron-builder will fail with 'electronDist does not exist'." + +def test_no_static_electron_dist_that_can_drift(): + """build.electronDist must not be a static path — hoisting is non-deterministic.""" + assert "electronDist" not in _desktop_pkg().get("build", {}), ( + "build.electronDist is hardcoded again. npm hoisting is non-deterministic, " + "so a static path silently breaks packaging when the layout changes. Let " + "scripts/run-electron-builder.cjs resolve it dynamically instead." + ) + + +def test_builder_script_routes_through_dynamic_resolver(): + """npm run builder must invoke run-electron-builder.cjs, not bare electron-builder.""" + builder = _desktop_pkg().get("scripts", {}).get("builder", "") + assert "run-electron-builder.cjs" in builder, ( + f"the 'builder' script must run scripts/run-electron-builder.cjs, got " + f"{builder!r}" + ) + assert ELECTRON_BUILDER_WRAPPER.is_file(), ( + f"missing dynamic-resolver wrapper at {ELECTRON_BUILDER_WRAPPER}" + ) + + +def test_resolver_uses_node_module_resolution(): + """Wrapper must resolve electron via require.resolve and pass -c.electronDist.""" + src = ELECTRON_BUILDER_WRAPPER.read_text(encoding="utf-8") + assert 'require.resolve("electron/package.json")' in src, ( + "run-electron-builder.cjs must resolve electron via " + "require.resolve('electron/package.json') to stay hoist-proof." + ) + # And it must hand the resolved dist to electron-builder as an override. + assert "-c.electronDist=" in src, ( + "run-electron-builder.cjs must pass the resolved dist to electron-builder " + "via -c.electronDist." )
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#49152 fix(desktop): pre-fetch Electron dist after clean install on update (#47917) · done elsewhere →
#48091,#47917
Closed issues · 9
- Closed#46785Bootstrap installer misdiagnoses desktop build failures as Electron download problems
- Closed#47266Windows install fails at desktop stage: electronDist does not exist (Electron download unreliable)
- Closed#47917[Bug] Desktop build fails after update - electronDist does not exist (cache invalidated)
- Closed#48019Windows desktop pack fails: electronDist points to repo-root node_modules instead of workspace-local Electron
- Closed#48021Desktop installer: Electron binary not downloaded, build fails on fresh macOS install
- Closed#48059[Bug]: macOS desktop build fails — `electronDist does not exist` because the lockfile installs `electron` under `apps/desktop/node_modules` while electron-builder expects it hoisted to the workspace root
- Closed#48084bug(desktop): `electronDist` path mismatch — electron in workspace but path points to repo root
- Closed#56834Desktop build fails on Linux with electron error
- Closed#67166Installer misdiagnoses ENOSPC as a blocked Electron download, then burns more disk retrying
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.11: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab