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

0open issues
0open PRs
13items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #47276 fix(desktop): re-download Electron binary via mirror when pack fails (#47266)

    merged to main

    files: hermes_cli/main.py, scripts/install.ps1, scripts/install.sh, tests/hermes_cli/test_gui_command.py

    Show 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

    merged to main

    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.py

    Show 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)

    merged to main

    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.py

    Show 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.00 means 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

At a glance

Subsystem
apps/bootstrap-installer
Merged PRs
#47276, #48081, #48091
Closed issues
9
Attempts
1 PR closed without merging
Fix link checked
17 Jun 2026

For your agent

The same facts, typed.