hermes-agent/nix/desktop.nix
emozilla 2d206a3a42 fix(desktop): stop hermes desktop from clobbering tracked main.cjs (#52735)
`npm run build` ended with `bundle-electron-main.mjs`, which esbuild-bundled
electron/main.cjs and renamed the bundle on top of the tracked source file.
Because every `hermes desktop` runs `npm run build`, each launch rewrote a
checked-in source file (~7.5k-line source -> ~14.8k-line bundle), dirtying the
working tree with a build artifact that `git restore` couldn't keep (the next
launch re-clobbered it) and forcing autostash/restore conflicts on update.

The bundle only existed to inline `simple-git` so the packaged app.asar (which
ships no node_modules) wouldn't crash at launch with "Cannot find module
'simple-git'". Replace it with the mechanism the repo already uses for the
other hoisted runtime dep (node-pty): stage the dependency closure and resolve
it from process.resourcesPath at runtime.

- stage-native-deps.cjs: resolve simple-git's runtime closure (walking
  dependencies + optionalDependencies, so a version bump that adds a transitive
  dep can't silently reintroduce the crash) and stage it under
  build/native-deps/vendor/node_modules/. The `vendor/` nesting is load-bearing:
  electron-builder drops a node_modules dir at the ROOT of an extraResources
  copy but keeps a nested one.
- git-review-ops.cjs: fall back to the staged
  native-deps/vendor/node_modules/simple-git when the hoisted require() fails;
  dev runs resolve the hoisted copy and never hit the fallback.
- package.json: drop the bundler from the `build` script so main.cjs is never a
  build target again.
- nix/desktop.nix: drop the direct bundler call (the closure rides the existing
  `cp -rn native-deps` into $out) and patch process.resourcesPath in
  git-review-ops.cjs alongside main.cjs.
- delete scripts/bundle-electron-main.mjs.

Verified: electron-builder's own file filter keeps the full staged closure
(0 dropped), and a packaged win-unpacked build launches with the git-review
pane resolving simple-git from the staged vendor path.
2026-06-28 01:30:09 -04:00

168 lines
6.1 KiB
Nix

# nix/desktop.nix — Hermes Desktop (Electron) app build + wrapper
#
# `hermesAgent` is the fully-built `.#default` package — it ships the
# `hermes` binary with the venv, runtime PATH, bundled skills/plugins, etc.
# already wired up. We point the desktop at it via the existing
# `HERMES_DESKTOP_HERMES` override env var, so the desktop's resolver
# uses our fully wrapped binary at step 4 ("existing Hermes CLI").
# No reimplementation of the agent resolution in this wrapper.
{
pkgs,
lib,
stdenv,
makeWrapper,
hermesNpmLib,
electron,
hermesAgent,
...
}:
let
npm = hermesNpmLib.mkNpmPassthru {
folder = "apps/desktop";
attr = "desktop";
pname = "hermes-desktop";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (
npm
// {
pname = "hermes-desktop-renderer";
inherit version;
doCheck = true;
buildPhase = ''
runHook preBuild
# write-build-stamp.cjs replacement. Packaged Electron reads this
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
# patch shebangs in node_modules/.bin so npm exec can find the
# nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox)
patchShebangs .
pushd apps/desktop
# stage node-pty native binaries into build/native-deps for the final nix output
npm rebuild node-pty --build-from-source
node scripts/stage-native-deps.cjs
npm exec tsc -b
npm exec vite build
# simple-git is the electron main's external runtime dep. It is not
# bundled into main.cjs; instead the stage-native-deps.cjs call above
# copies its closure to apps/desktop/build/native-deps/vendor/node_modules/,
# which installPhase ships into $out/native-deps/ the same path the
# packaged app uses. electron/git-review-ops.cjs resolves it from
# process.resourcesPath when the hoisted require() isn't reachable
# (see issue #52735). node-pty's prebuilt is staged the same way;
# electron is provided by the runtime. preload.cjs stays separate
# Electron loads it via __dirname, not require().
popd
runHook postBuild
'';
checkPhase = ''
runHook preCheck
pushd apps/desktop
npm run postbuild
# validate staged node-pty native binary is present
STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node"
if [ ! -f "$STAGED_PTY_NODE" ]; then
echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE"
echo "node-pty must be compiled natively"
exit 1
fi
popd
runHook postCheck
'';
installPhase = ''
runHook preInstall
mkdir -p $out
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
# apps/desktop/build was created before the cd. electron/ is source.
cp -rn apps/desktop/dist $out/
cp -rn apps/desktop/electron $out/
# flatten native-deps and install-stamp.json to the root level, exactly like
# electron-builder's extraResources does ("from": "build/native-deps", "to": "native-deps")
# so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty'
cp -rn apps/desktop/build/native-deps $out/
cp -n apps/desktop/build/install-stamp.json $out/
cp -n apps/desktop/package.json $out/
runHook postInstall
'';
}
);
in
# Electron wrapper: nixpkgs' electron binary pointed at the renderer dir.
stdenv.mkDerivation {
pname = "hermes-desktop";
inherit version;
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/hermes-desktop $out/bin
cp -r ${renderer}/* $out/share/hermes-desktop/
# Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath
# to point to the app's directory. In Nix, unpackaged electron defaults this
# to the electron distribution's resources path, breaking extraResources lookups.
substituteInPlace $out/share/hermes-desktop/electron/main.cjs \
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
# git-review-ops.cjs has the same process.resourcesPath fallback for its
# staged simple-git dep (native-deps/vendor/node_modules/), so it needs the same
# rewrite otherwise the require() fallback resolves against the electron
# dist's resources path and fails to load simple-git (issue #52735).
substituteInPlace $out/share/hermes-desktop/electron/git-review-ops.cjs \
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
# Wrap the nixpkgs electron binary to launch our app. Set
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
# PATH") uses our fully wrapped binary venv with all deps,
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
# No reimplementation of the agent resolver in the wrapper.
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
--add-flags "$out/share/hermes-desktop" \
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
--set ELECTRON_IS_DEV 0
runHook postInstall
'';
passthru = {
inherit (renderer.passthru) packageJsonPath;
};
meta = with lib; {
description = "Native Electron desktop shell for Hermes Agent";
homepage = "https://github.com/NousResearch/hermes-agent";
license = licenses.mit;
platforms = platforms.unix;
mainProgram = "hermes-desktop";
};
}