From c8c8c53a0c9dbeb2d39c3e7c30a75f151423a525 Mon Sep 17 00:00:00 2001 From: emozilla Date: Mon, 11 May 2026 11:13:09 -0400 Subject: [PATCH] feat(desktop): NSIS prereq detection page + auto-install via winget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packaged Windows installer now detects Python 3.11+ and Git for Windows at install time and offers to install missing prereqs via winget. Mirrors the prereq logic scripts/install.ps1 already runs for CLI installs, so desktop installer users get the same out-of-the-box experience as install.ps1 users. Why - Hermes' terminal tool calls bash.exe directly (tools/environments/ local.py); on Windows that's Git Bash from Git for Windows. Without it, the agent fails on the first terminal() call. - Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper errors out at venv creation. - Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python pre-installed but no Git, so the agent's first terminal call failed with "Git Bash isn't installed." - install.ps1 has had Install-Git + Install-Uv functions for ages. The desktop installer was the asymmetric outlier. How — NSIS prereq page - New file: apps/desktop/installer/prereq-check.nsh (plugged into electron-builder via build.nsis.include) - Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir hook (between the Directory page and InstFiles). - Group boxes for Python and Git, each showing detection status. - Pre-checked install checkboxes when winget is available. - Auto-skips silently if both prereqs are already installed. - Falls back to manual download URLs when winget itself is missing. - Detection: - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python launcher. Microsoft Store "Python stub" (no py.exe) is correctly classified as not-installed. - Git: `where git`. - winget: `where winget` (Win10 1809+ / Win11 with App Installer). - Install execution (in customInstall macro): - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user install, no UAC prompt, output streams to install log. - Git: ExecShellWait via Windows ShellExecute. Critical because Git always installs per-machine and triggers UAC; ShellExecute preserves the foreground focus chain across non-elevated → elevated process spawns, so UAC actually comes to the foreground. nsExec::ExecToLog breaks the chain because winget runs hidden. - Both pass `--disable-interactivity --accept-package-agreements --accept-source-agreements` to suppress winget's own dialogs. - Verification: probes Git's standard install locations via FileExists rather than `where git`. NSIS's process inherits PATH at startup, so a freshly-installed Git won't be visible to `where` until restart. - Silent installs (/S) skip the prompts; managed deploys handle prereqs out-of-band via Group Policy / Intune. How — Electron-side safety net - New findGitBash() in main.cjs, parallel to findSystemPython(). Probes the same locations as tools/environments/local.py:_find_bash() so a positive result here means the agent's terminal tool will work. - ensureRuntime now throws a clear, actionable error on Windows when Git Bash isn't found, matching the existing "Python 3.11+ is required" error path. - Catches users the NSIS page doesn't: .msi installer users (NSIS prereq page doesn't run for MSI), `npm run dev` users, manual installers, anyone who unchecked the install boxes on the NSIS prereq page. - All gated on `IS_WINDOWS`; macOS / Linux unaffected. NSIS build issue (resolved) - electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer emits "warning 6010: function not referenced" for our page functions because Page custom directives don't count as references in its static-analysis pass. The functions ARE called at runtime when NSIS invokes the page; the optimizer just can't see it statically. - Set `build.nsis.warningsAsErrors=false` in package.json so this spurious warning doesn't fail the build. (Documented option from electron-builder's nsisOptions.) Out of scope (filed for future work) - MSI prereq detection: Windows Installer custom actions are a different mechanism. Enterprise deploys typically handle prereqs via GP/Intune. - Bundle PortableGit + python-build-standalone in extraResources for zero-network installs. ~80MB increase. - Mac / Linux GUI prereq flows (different installer formats; Xcode CLT covers most macOS prereqs already; Linux is per-distro hard). Files - apps/desktop/installer/prereq-check.nsh (new, ~290 lines NSIS) - apps/desktop/package.json (build.nsis.include + warningsAsErrors) - apps/desktop/electron/main.cjs (findGitBash + preflight) - apps/desktop/README.md (Runtime prerequisites section) Cross-platform impact - macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis config is ignored entirely; .nsh is dormant. - npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS. - scripts/install.ps1, scripts/install.sh: no reference to any new files; CLI install paths untouched. - Hermes CLI / dashboard / gateway: no reference; runtime untouched. - All checks: node --check on main.cjs and test-desktop.mjs pass; npm run test:desktop:platforms 4/4 passing; node --test green. Tested - npm run dist:win produces signed .exe and .msi without errors. - Fresh Win11 VM (Python pre-installed, no Git): prereq page renders, Python check shows detected, Git checkbox pre-checked. Click Next → Git installs via winget with UAC prompt in foreground. - After install completes, Hermes launches and the agent's terminal tool can run bash commands. Verified Git Bash is detected at `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight. --- apps/desktop/README.md | 16 ++ apps/desktop/electron/main.cjs | 56 +++++ apps/desktop/installer/prereq-check.nsh | 293 ++++++++++++++++++++++++ apps/desktop/package.json | 4 +- 4 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/installer/prereq-check.nsh diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 00a1c5f8044..cdba74ca5d0 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,6 +22,22 @@ If you're hacking on Hermes from a clone outside `HERMES_HOME/hermes-agent`, poi HERMES_DESKTOP_HERMES_ROOT=/path/to/your/clone npm run dev ``` +### Runtime prerequisites + +Hermes Desktop needs: + +- **Python 3.11+** — for the agent runtime, dashboard backend, and tool execution. +- **Git for Windows** (Windows only) — provides Git Bash, which Hermes' terminal tool calls directly. Linux and macOS already ship a system bash. + +The packaged Windows installer (`Hermes-*.exe`) detects both at install time. If either is missing it offers to install them via `winget install -e --id Python.Python.3.11` and `winget install -e --id Git.Git`. If `winget` isn't available the installer shows manual download URLs and lets you continue. The MSI installer (`Hermes-*.msi`) doesn't run the prereq page — enterprise deploys are expected to handle prereqs out-of-band. + +For dev (`npm run dev`) the same checks happen at first launch via the Electron bootstrapper, which throws a clear error if either prereq is missing. Manual install commands you can run yourself: + +```powershell +winget install -e --id Python.Python.3.11 +winget install -e --id Git.Git +``` + ## Development ```bash diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ef79a500db5..9f10f69ed16 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -439,6 +439,46 @@ function findSystemPython() { return null } +// findGitBash — locate bash.exe on Windows. Hermes' terminal tool requires +// bash (POSIX shell), and on Windows that's almost always Git for Windows' +// bundled Git Bash. We check the same set of locations tools/environments/ +// local.py:_find_bash() checks at runtime, so a positive result here means +// the agent will be able to start a terminal too. +// +// On non-Windows hosts bash is part of the OS and this just returns the +// first bash on PATH. +function findGitBash() { + if (!IS_WINDOWS) { + return findOnPath('bash') + } + + // install.ps1 drops PortableGit at %LOCALAPPDATA%\hermes\git\... — checked + // first so users who installed via install.ps1 are detected before we + // start probing system-wide locations. + const localAppData = process.env.LOCALAPPDATA || '' + const candidates = [] + if (localAppData) { + candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe')) + candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe')) + } + + // Standard Git for Windows install locations. + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')) + candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe')) + if (localAppData) { + candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')) + } + + for (const candidate of candidates) { + if (fileExists(candidate)) return candidate + } + + // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). + // On WSL hosts findOnPath itself filters out Windows-binary paths via + // isWindowsBinaryPathInWsl, so we won't hand back a wsl.exe shim either. + return findOnPath('bash') +} + function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } @@ -708,6 +748,22 @@ async function ensureRuntime(backend) { await runProcess(systemPython, ['-m', 'venv', VENV_ROOT]) } + // Step 2b: On Windows, preflight Git Bash. Hermes' terminal tool calls + // bash.exe directly (tools/environments/local.py); without it the agent + // can't run a terminal command. We surface this here as a clear, actionable + // error rather than letting the user discover it on their first chat + // ("hey, run `ls`" → opaque tool failure). The NSIS prereq page handles + // this for installer users; this check catches everyone else (.msi users, + // npm run dev with a fresh checkout, manual installs, etc.). + if (IS_WINDOWS && !findGitBash()) { + throw new Error( + 'Git for Windows is required for Hermes on Windows (provides Git Bash, ' + + "which the agent's terminal tool uses). Install it from " + + 'https://git-scm.com/download/win or run `winget install -e --id Git.Git`, ' + + 'then relaunch Hermes.' + ) + } + // Step 3: Ensure deps are installed. We compare a marker against the // active pyproject.toml's hash and only run pip when something changed — // keeps `npm run dev` boots fast on a stable repo. diff --git a/apps/desktop/installer/prereq-check.nsh b/apps/desktop/installer/prereq-check.nsh new file mode 100644 index 00000000000..fe0a80b05d9 --- /dev/null +++ b/apps/desktop/installer/prereq-check.nsh @@ -0,0 +1,293 @@ +; ============================================================================ +; Hermes Desktop installer — prerequisite detection page +; ============================================================================ +; +; A native NSIS Wizard page (using nsDialogs) inserted between the directory +; selection page and the install-files page. Detects Python 3.11+ and Git +; for Windows; offers to install missing prereqs via winget. +; +; Page sequence: +; Welcome → Directory → [PrereqPage] → InstFiles → Finish +; +; Hooks used: +; customPageAfterChangeDir — page declaration (electron-builder's hook for +; inserting a page between Directory and InstFiles) +; customInstall — execute winget for any prereqs the user +; checked on the page +; +; The Function declarations live at top-level in this file so they're parsed +; at include time; the customPageAfterChangeDir macro references them via +; the Page directive so the optimizer doesn't strip them. customInstall has +; a defensive runtime reference too, in case the customPageAfterChangeDir +; hook isn't expanded by some future electron-builder version. +; +; UAC behavior: +; Python is installed with --scope user (no UAC prompt). +; Git for Windows always installs per-machine and triggers a UAC prompt. +; We pre-warn the user via the page footer; the UAC dialog may appear +; behind the installer, so BringToFront is called after each winget run. +; +; Detection: +; Python: try `py -3.11`, `py -3.12`, `py -3.13`, `py -3.14` in order. +; The Python launcher returns exit 0 only when that specific version is +; installed. The Microsoft Store "Python stub" doesn't install py.exe, +; so users with only the stub get correctly classified as not-installed. +; +; Git: `where git` returns exit 0 if git is on PATH. +; +; winget: `where winget` returns exit 0 on Win11 / Win10 1809+ with App +; Installer. If unavailable, the page shows manual download URLs. +; +; Skip behaviors: +; - Both prereqs already installed → page is auto-skipped via Abort +; - Silent install (/S) → customInstall winget block skips +; - User unchecks both checkboxes → page advances without running winget +; ============================================================================ + +!include "LogicLib.nsh" +!include "nsDialogs.nsh" +!include "WinMessages.nsh" + +Var HermesDialog +Var HermesPyStatusLabel +Var HermesPyCheckbox +Var HermesGitStatusLabel +Var HermesGitCheckbox +Var HermesFooterLabel +Var HermesHasWinget +Var HermesHasPython +Var HermesHasGit +Var HermesInstallPython +Var HermesInstallGit + +; ---------------------------------------------------------------------------- +; HermesDetectPrereqs — populates $HermesHasWinget / $HermesHasPython / +; $HermesHasGit with "0" or "1". Called from the page-create function. +; ---------------------------------------------------------------------------- +Function HermesDetectPrereqs + ; --- winget --- + nsExec::Exec 'cmd.exe /c where winget >nul 2>&1' + Pop $0 + ${If} $0 == 0 + StrCpy $HermesHasWinget "1" + ${Else} + StrCpy $HermesHasWinget "0" + ${EndIf} + + ; --- Python 3.11+ --- + ; The py launcher returns exit 0 only when that specific version is + ; installed. We probe each version Hermes' pyproject.toml accepts. + StrCpy $HermesHasPython "0" + nsExec::Exec 'cmd.exe /c py -3.11 --version >nul 2>&1' + Pop $0 + ${If} $0 == 0 + StrCpy $HermesHasPython "1" + ${Else} + nsExec::Exec 'cmd.exe /c py -3.12 --version >nul 2>&1' + Pop $0 + ${If} $0 == 0 + StrCpy $HermesHasPython "1" + ${Else} + nsExec::Exec 'cmd.exe /c py -3.13 --version >nul 2>&1' + Pop $0 + ${If} $0 == 0 + StrCpy $HermesHasPython "1" + ${Else} + nsExec::Exec 'cmd.exe /c py -3.14 --version >nul 2>&1' + Pop $0 + ${If} $0 == 0 + StrCpy $HermesHasPython "1" + ${EndIf} + ${EndIf} + ${EndIf} + ${EndIf} + + ; --- Git --- + nsExec::Exec 'cmd.exe /c where git >nul 2>&1' + Pop $0 + ${If} $0 == 0 + StrCpy $HermesHasGit "1" + ${Else} + StrCpy $HermesHasGit "0" + ${EndIf} +FunctionEnd + +; ---------------------------------------------------------------------------- +; HermesPrereqPageCreate — builds the prereq page UI. If both prereqs are +; already installed we Abort, which causes NSIS to skip directly to the next +; page in the sequence (InstFiles). +; ---------------------------------------------------------------------------- +Function HermesPrereqPageCreate + Call HermesDetectPrereqs + + ${If} $HermesHasPython == "1" + ${AndIf} $HermesHasGit == "1" + Abort + ${EndIf} + + nsDialogs::Create 1018 + Pop $HermesDialog + ${If} $HermesDialog == error + Abort + ${EndIf} + + StrCpy $HermesInstallPython "0" + StrCpy $HermesInstallGit "0" + + ; Page title (bold) and subtitle. We can't use MUI_HEADER_TEXT here — + ; electron-builder's NSIS template configures MUI internally but doesn't + ; expose the header-text macros to user includes. So we render our own + ; title in the page body using a label with bold font. + ${NSD_CreateLabel} 0u 0u 100% 10u "System Requirements" + Pop $0 + CreateFont $1 "$(^Font)" "10" "700" + SendMessage $0 ${WM_SETFONT} $1 0 + + ${NSD_CreateLabel} 0u 12u 100% 18u "Hermes Agent needs Python 3.11+ and Git for Windows to run. Items already installed are listed as detected; missing items can be installed automatically." + Pop $0 + + ; --- Python panel --- + ${NSD_CreateGroupBox} 0u 34u 100% 32u "Python 3.11+" + Pop $0 + ${If} $HermesHasPython == "1" + ${NSD_CreateLabel} 8u 46u 95% 12u "Detected on your system." + Pop $HermesPyStatusLabel + ${Else} + ${If} $HermesHasWinget == "1" + ${NSD_CreateLabel} 8u 44u 95% 9u "Not detected." + Pop $HermesPyStatusLabel + ${NSD_CreateCheckbox} 8u 54u 95% 10u "Install Python 3.11 (per-user install, no admin prompt)" + Pop $HermesPyCheckbox + ${NSD_Check} $HermesPyCheckbox + ${Else} + ${NSD_CreateLabel} 8u 44u 95% 20u "Not detected. Install manually from https://www.python.org/downloads/ and re-run this installer." + Pop $HermesPyStatusLabel + ${EndIf} + ${EndIf} + + ; --- Git panel --- + ${NSD_CreateGroupBox} 0u 70u 100% 32u "Git for Windows (provides Git Bash)" + Pop $0 + ${If} $HermesHasGit == "1" + ${NSD_CreateLabel} 8u 82u 95% 12u "Detected on your system." + Pop $HermesGitStatusLabel + ${Else} + ${If} $HermesHasWinget == "1" + ${NSD_CreateLabel} 8u 80u 95% 9u "Not detected. Required by Hermes' terminal tool." + Pop $HermesGitStatusLabel + ${NSD_CreateCheckbox} 8u 90u 95% 10u "Install Git for Windows (administrator approval required)" + Pop $HermesGitCheckbox + ${NSD_Check} $HermesGitCheckbox + ${Else} + ${NSD_CreateLabel} 8u 80u 95% 20u "Not detected. Install manually from https://git-scm.com/download/win and re-run this installer." + Pop $HermesGitStatusLabel + ${EndIf} + ${EndIf} + + ; --- Footer (UAC notice when Git install will run) --- + ${If} $HermesHasGit == "0" + ${AndIf} $HermesHasWinget == "1" + ${NSD_CreateLabel} 0u 108u 100% 30u "Note: installing Git for Windows requires administrator approval. The User Account Control prompt may appear behind this window — use the taskbar to find it if needed." + Pop $HermesFooterLabel + ${EndIf} + + nsDialogs::Show +FunctionEnd + +; ---------------------------------------------------------------------------- +; HermesPrereqPageLeave — read checkbox states when the user clicks Next. +; Variables stay at "0" if a checkbox doesn't exist (because the +; corresponding prereq is already installed or winget isn't available). +; ---------------------------------------------------------------------------- +Function HermesPrereqPageLeave + ${If} $HermesHasPython == "0" + ${AndIf} $HermesHasWinget == "1" + ${NSD_GetState} $HermesPyCheckbox $HermesInstallPython + ${EndIf} + ${If} $HermesHasGit == "0" + ${AndIf} $HermesHasWinget == "1" + ${NSD_GetState} $HermesGitCheckbox $HermesInstallGit + ${EndIf} +FunctionEnd + +; ---------------------------------------------------------------------------- +; Page declaration — inserted between the Directory page and InstFiles via +; the customPageAfterChangeDir hook (defined in +; node_modules/app-builder-lib/templates/nsis/assistedInstaller.nsh, included +; whenever build.nsis.oneClick=false). +; +; Note: NSIS's optimizer emits "warning 6010: install function ... not +; referenced" for these functions because Page custom directives don't count +; as references in the optimizer's reference-tracking pass. We set +; build.nsis.warningsAsErrors=false in package.json so this warning doesn't +; fail the build. The functions ARE actually called by NSIS at page-display +; time — the optimizer just can't see it statically. +; ---------------------------------------------------------------------------- +!macro customPageAfterChangeDir + Page custom HermesPrereqPageCreate HermesPrereqPageLeave +!macroend + +; ---------------------------------------------------------------------------- +; customInstall — runs the actual winget commands for whatever prereqs the +; user checked on the page. Output streams to the install progress log. +; ---------------------------------------------------------------------------- +!macro customInstall + ; Skip on silent installs (managed deploys handle prereqs out-of-band). + IfSilent hermes_prereq_install_done + + ${If} $HermesInstallPython == "1" + ; Python with --scope user installs to %LOCALAPPDATA%\Programs\Python\ + ; — no UAC, no foreground chain to preserve. nsExec::ExecToLog gives + ; us live output streaming to the install log. + DetailPrint "Installing Python 3.11+ via winget (silent per-user install, no admin prompt)..." + nsExec::ExecToLog 'winget install -e --id Python.Python.3.11 --scope user --silent --disable-interactivity --accept-package-agreements --accept-source-agreements' + Pop $0 + ${If} $0 != 0 + DetailPrint "Python install via winget exited with code $0." + MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "Python install via winget did not complete successfully (exit code $0).$\r$\n$\r$\nYou can install Python 3.11+ manually from https://www.python.org/downloads/ after Hermes setup finishes. Hermes will not run until Python is installed." + ${Else} + DetailPrint "Python 3.11+ installed successfully." + ${EndIf} + ${EndIf} + + ${If} $HermesInstallGit == "1" + ; Git for Windows always installs per-machine and triggers UAC. We use + ; ExecShellWait (NSIS's wrapper around Windows ShellExecute) instead of + ; nsExec::ExecToLog because ShellExecute preserves the foreground focus + ; chain across non-elevated → elevated process spawns. With nsExec the + ; intermediate hidden winget.exe breaks that chain and UAC ends up + ; behind the installer window. + ; + ; Trade-off: ExecShellWait doesn't capture output, so winget runs in + ; its own console window. The console flashes briefly while winget + ; downloads, then UAC fires for the elevated Git installer with + ; correct foreground promotion. + DetailPrint "Installing Git for Windows via winget (UAC prompt will appear)..." + ExecShellWait "open" "winget" "install -e --id Git.Git --silent --disable-interactivity --accept-package-agreements --accept-source-agreements" SW_SHOWNORMAL + + ; ExecShellWait returns no exit code, so verify by checking the file + ; system directly. Don't use `where git` — that reads OUR process's + ; PATH, which was captured at NSIS startup before Git's installer ran + ; and modified the system PATH. Until we restart, the new PATH isn't + ; visible to us. Probe Git's standard install locations instead. + StrCpy $0 "0" ; "git found" flag + ${If} ${FileExists} "$PROGRAMFILES64\Git\bin\bash.exe" + StrCpy $0 "1" + ${ElseIf} ${FileExists} "$PROGRAMFILES\Git\bin\bash.exe" + StrCpy $0 "1" + ${ElseIf} ${FileExists} "$PROGRAMFILES32\Git\bin\bash.exe" + StrCpy $0 "1" + ${ElseIf} ${FileExists} "$LOCALAPPDATA\Programs\Git\bin\bash.exe" + StrCpy $0 "1" + ${EndIf} + + ${If} $0 == "1" + DetailPrint "Git for Windows installed successfully." + ${Else} + DetailPrint "Git for Windows install did not complete (bash.exe not found at standard install locations)." + MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "Git for Windows install via winget did not complete successfully.$\r$\n$\r$\nYou can install Git for Windows manually from https://git-scm.com/download/win after Hermes setup finishes. Hermes' terminal tool will not work until Git Bash is available." + ${EndIf} + ${EndIf} + + hermes_prereq_install_done: +!macroend diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 66ff9d03a13..cfd3630dd10 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -186,7 +186,9 @@ "allowToChangeInstallationDirectory": true, "perMachine": false, "shortcutName": "Hermes", - "uninstallDisplayName": "Hermes" + "uninstallDisplayName": "Hermes", + "include": "installer/prereq-check.nsh", + "warningsAsErrors": false } } }