11import { spawn , execSync , execFile } from "child_process" ;
2- import { existsSync , readFileSync , readdirSync } from "fs" ;
3- import { join } from "path" ;
4- import { homedir } from "os" ;
2+ import { existsSync , readFileSync , readdirSync , writeFileSync , unlinkSync } from "fs" ;
3+ import { join , delimiter } from "path" ;
4+ import { homedir , tmpdir } from "os" ;
5+ import { randomBytes } from "crypto" ;
56import type { BrowserWindow } from "electron" ;
67import { getModelConfig , getConnectionConfig } from "./config" ;
78import { stripAnsi } from "./utils" ;
89import { setupAskpass , AskpassHandle } from "./askpass" ;
910
11+ const IS_WINDOWS = process . platform === "win32" ;
12+
1013export const HERMES_HOME =
1114 process . env . HERMES_HOME ?. trim ( ) || join ( homedir ( ) , ".hermes" ) ;
1215export const HERMES_REPO = join ( HERMES_HOME , "hermes-agent" ) ;
1316export const HERMES_VENV = join ( HERMES_REPO , "venv" ) ;
14- export const HERMES_PYTHON = join ( HERMES_VENV , "bin" , "python" ) ;
17+ export const HERMES_PYTHON = IS_WINDOWS
18+ ? join ( HERMES_VENV , "Scripts" , "python.exe" )
19+ : join ( HERMES_VENV , "bin" , "python" ) ;
1520export const HERMES_SCRIPT = join ( HERMES_REPO , "hermes" ) ;
1621export const HERMES_ENV_FILE = join ( HERMES_HOME , ".env" ) ;
1722export const HERMES_CONFIG_FILE = join ( HERMES_HOME , "config.yaml" ) ;
@@ -34,21 +39,34 @@ export interface InstallProgress {
3439
3540export function getEnhancedPath ( ) : string {
3641 const home = homedir ( ) ;
37- const extra = [
38- join ( home , ".local" , "bin" ) ,
39- join ( home , ".cargo" , "bin" ) ,
40- join ( HERMES_VENV , "bin" ) ,
41- // Node version manager shim directories
42- join ( home , ".volta" , "bin" ) ,
43- join ( home , ".asdf" , "shims" ) ,
44- join ( home , ".local" , "share" , "fnm" , "aliases" , "default" , "bin" ) ,
45- join ( home , ".fnm" , "aliases" , "default" , "bin" ) ,
46- ...resolveNvmBin ( home ) ,
47- "/usr/local/bin" ,
48- "/opt/homebrew/bin" ,
49- "/opt/homebrew/sbin" ,
50- ] ;
51- return [ ...extra , process . env . PATH || "" ] . join ( ":" ) ;
42+ const extra : string [ ] = IS_WINDOWS
43+ ? [
44+ // Bundled by install.ps1 inside HERMES_HOME — these matter when the
45+ // user's system PATH doesn't include git or node yet.
46+ join ( HERMES_HOME , "git" , "bin" ) ,
47+ join ( HERMES_HOME , "git" , "cmd" ) ,
48+ join ( HERMES_HOME , "git" , "usr" , "bin" ) ,
49+ join ( HERMES_HOME , "node" ) ,
50+ join ( HERMES_VENV , "Scripts" ) ,
51+ // Where `uv` lands when astral.sh's installer runs.
52+ join ( home , ".local" , "bin" ) ,
53+ join ( home , ".cargo" , "bin" ) ,
54+ ]
55+ : [
56+ join ( home , ".local" , "bin" ) ,
57+ join ( home , ".cargo" , "bin" ) ,
58+ join ( HERMES_VENV , "bin" ) ,
59+ // Node version manager shim directories
60+ join ( home , ".volta" , "bin" ) ,
61+ join ( home , ".asdf" , "shims" ) ,
62+ join ( home , ".local" , "share" , "fnm" , "aliases" , "default" , "bin" ) ,
63+ join ( home , ".fnm" , "aliases" , "default" , "bin" ) ,
64+ ...resolveNvmBin ( home ) ,
65+ "/usr/local/bin" ,
66+ "/opt/homebrew/bin" ,
67+ "/opt/homebrew/sbin" ,
68+ ] ;
69+ return [ ...extra , process . env . PATH || "" ] . join ( delimiter ) ;
5270}
5371
5472/** Resolve the active nvm node version's bin directory. */
@@ -413,40 +431,44 @@ function getShellProfile(home: string): string | null {
413431 return null ;
414432}
415433
416- // Parse install.sh output to detect progress stages
434+ // Parse install.sh / install.ps1 output to detect progress stages.
435+ // Patterns are tuned to match both bash and PowerShell installer phrasing.
417436const STAGE_MARKERS : { pattern : RegExp ; step : number ; title : string } [ ] = [
418437 {
419- pattern : / C h e c k i n g f o r ( g i t | u v | p y t h o n ) / i,
438+ pattern : / C h e c k i n g ( f o r ) ? ( g i t | u v | p y t h o n | n o d e | r i p g r e p | f f m p e g ) / i,
420439 step : 1 ,
421440 title : "Checking prerequisites" ,
422441 } ,
423442 {
424- pattern : / I n s t a l l i n g u v | u v f o u n d / i,
443+ pattern : / I n s t a l l i n g u v | u v f o u n d | u v i n s t a l l e d / i,
425444 step : 2 ,
426445 title : "Setting up package manager" ,
427446 } ,
428447 {
429- pattern : / I n s t a l l i n g P y t h o n | P y t h o n .* f o u n d / i,
448+ pattern : / I n s t a l l i n g P y t h o n | P y t h o n .* f o u n d | P y t h o n i n s t a l l e d / i,
430449 step : 3 ,
431450 title : "Setting up Python" ,
432451 } ,
433452 {
434- pattern : / C l o n i n g | c l o n i n g | U p d a t i n g .* r e p o s i t o r y | R e p o s i t o r y / i,
453+ pattern :
454+ / C l o n i n g | c l o n i n g | U p d a t i n g .* r e p o s i t o r y | R e p o s i t o r y | I n s t a l l i n g t o .* h e r m e s - a g e n t | D o w n l o a d i n g P o r t a b l e G i t / i,
435455 step : 4 ,
436456 title : "Downloading Hermes Agent" ,
437457 } ,
438458 {
439- pattern : / C r e a t i n g v i r t u a l | v i r t u a l e n v i r o n m e n t | v e n v / i,
459+ pattern : / C r e a t i n g v i r t u a l | v i r t u a l e n v i r o n m e n t | u v v e n v | \b v e n v \b / i,
440460 step : 5 ,
441461 title : "Creating Python environment" ,
442462 } ,
443463 {
444- pattern : / p i p i n s t a l l | I n s t a l l i n g .* p a c k a g e s | d e p e n d e n c i e s / i,
464+ pattern :
465+ / p i p i n s t a l l | I n s t a l l i n g .* p a c k a g e s | d e p e n d e n c i e s | T r y i n g t i e r | R e s o l v i n g | M a i n p a c k a g e i n s t a l l e d / i,
445466 step : 6 ,
446467 title : "Installing dependencies" ,
447468 } ,
448469 {
449- pattern : / C o n f i g u r a t i o n | c o n f i g | S e t u p c o m p l e t e | I n s t a l l a t i o n c o m p l e t e / i,
470+ pattern :
471+ / C o n f i g u r a t i o n | c o n f i g | S e t u p c o m p l e t e | I n s t a l l a t i o n c o m p l e t e | C o n f i g u r a t i o n d i r e c t o r y r e a d y | h e r m e s c o m m a n d r e a d y | A l l d e p e n d e n c i e s i n s t a l l e d / i,
450472 step : 7 ,
451473 title : "Finishing setup" ,
452474 } ,
@@ -484,17 +506,19 @@ export async function runInstall(
484506
485507 emit ( "Running official Hermes install script...\n" ) ;
486508
509+ if ( IS_WINDOWS ) {
510+ return runInstallWindows ( emit ) ;
511+ }
512+
487513 // Bridge any sudo prompts from install.sh to a GUI password dialog.
488514 // Windows has no sudo, so skip the bridge there.
489515 let askpass : AskpassHandle | null = null ;
490- if ( process . platform !== "win32" ) {
491- try {
492- askpass = await setupAskpass ( parentWindow ?? null ) ;
493- } catch ( err ) {
494- emit (
495- `\n[askpass] Could not set up GUI password bridge: ${ ( err as Error ) . message } \n` ,
496- ) ;
497- }
516+ try {
517+ askpass = await setupAskpass ( parentWindow ?? null ) ;
518+ } catch ( err ) {
519+ emit (
520+ `\n[askpass] Could not set up GUI password bridge: ${ ( err as Error ) . message } \n` ,
521+ ) ;
498522 }
499523
500524 try {
@@ -563,6 +587,148 @@ export async function runInstall(
563587 }
564588}
565589
590+ // PS single-quoted string escape: ' → ''
591+ function psQuote ( s : string ) : string {
592+ return `'${ s . replace ( / ' / g, "''" ) } '` ;
593+ }
594+
595+ // Resolve a powershell executable. Prefer PowerShell 7 (`pwsh`) when present,
596+ // fall back to Windows PowerShell 5.1 (`powershell.exe`). Both ship the same
597+ // flags we use; pwsh is faster and writes UTF-8 without a BOM by default.
598+ function resolvePowerShellExe ( ) : string {
599+ // Spawn will resolve from PATH; we test for pwsh.exe first.
600+ const programFiles = process . env [ "ProgramFiles" ] ;
601+ const candidates = [
602+ programFiles ? join ( programFiles , "PowerShell" , "7" , "pwsh.exe" ) : null ,
603+ "pwsh.exe" ,
604+ "powershell.exe" ,
605+ ] . filter ( ( p ) : p is string => Boolean ( p ) ) ;
606+ for ( const c of candidates ) {
607+ if ( c . includes ( "\\" ) && existsSync ( c ) ) return c ;
608+ }
609+ // Let spawn search PATH for the bare names; powershell.exe ships on every
610+ // supported Windows version, so this is always resolvable.
611+ return "powershell.exe" ;
612+ }
613+
614+ async function runInstallWindows ( emit : ( t : string ) => void ) : Promise < void > {
615+ // We can't `irm | iex` and pass parameters, and we want to override the
616+ // upstream defaults (which install to %LOCALAPPDATA%\hermes) so the
617+ // desktop app's HERMES_HOME == ~\.hermes convention keeps working.
618+ // Strategy: write a small wrapper .ps1 to %TEMP%, run it with -File.
619+ const home = homedir ( ) ;
620+ const hermesHome = HERMES_HOME ;
621+ const installDir = HERMES_REPO ;
622+
623+ const wrapperPath = join (
624+ tmpdir ( ) ,
625+ `hermes-install-${ randomBytes ( 6 ) . toString ( "hex" ) } .ps1` ,
626+ ) ;
627+
628+ // The wrapper downloads install.ps1 to a sibling temp file and invokes it
629+ // with our parameters. This sidesteps the `iex`-can't-pass-args limitation.
630+ const wrapperScript = [
631+ "$ErrorActionPreference = 'Stop'" ,
632+ // Force TLS 1.2 for older Windows PowerShell 5.1 hosts that still default
633+ // to TLS 1.0 — github raw refuses TLS < 1.2.
634+ "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}" ,
635+ "$url = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1'" ,
636+ `$installer = Join-Path $env:TEMP ("hermes-install-script-" + [guid]::NewGuid().ToString() + ".ps1")` ,
637+ "Invoke-RestMethod -Uri $url -OutFile $installer" ,
638+ `& $installer -SkipSetup -HermesHome ${ psQuote ( hermesHome ) } -InstallDir ${ psQuote ( installDir ) } ` ,
639+ "$exit = $LASTEXITCODE" ,
640+ "Remove-Item -Force -ErrorAction SilentlyContinue $installer" ,
641+ "exit $exit" ,
642+ "" ,
643+ ] . join ( "\r\n" ) ;
644+
645+ try {
646+ writeFileSync ( wrapperPath , wrapperScript , { encoding : "utf8" } ) ;
647+ } catch ( err ) {
648+ throw new Error (
649+ `Failed to stage Windows installer: ${ ( err as Error ) . message } ` ,
650+ ) ;
651+ }
652+
653+ const psExe = resolvePowerShellExe ( ) ;
654+ const basePath = getEnhancedPath ( ) ;
655+
656+ return new Promise < void > ( ( resolve , reject ) => {
657+ const proc = spawn (
658+ psExe ,
659+ [
660+ "-ExecutionPolicy" ,
661+ "Bypass" ,
662+ "-NoProfile" ,
663+ "-NonInteractive" ,
664+ "-File" ,
665+ wrapperPath ,
666+ ] ,
667+ {
668+ cwd : home ,
669+ env : {
670+ ...process . env ,
671+ PATH : basePath ,
672+ HERMES_HOME : hermesHome ,
673+ // Hint that we're not interactive so install.ps1 doesn't `pause`
674+ // (the .cmd wrapper does on failure, but -File on .ps1 won't).
675+ NO_COLOR : "1" ,
676+ } ,
677+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
678+ windowsHide : true ,
679+ } ,
680+ ) ;
681+
682+ proc . stdout ?. on ( "data" , ( data : Buffer ) => {
683+ emit ( stripAnsi ( data . toString ( ) ) ) ;
684+ } ) ;
685+
686+ proc . stderr ?. on ( "data" , ( data : Buffer ) => {
687+ emit ( stripAnsi ( data . toString ( ) ) ) ;
688+ } ) ;
689+
690+ proc . on ( "close" , ( code ) => {
691+ try {
692+ unlinkSync ( wrapperPath ) ;
693+ } catch {
694+ /* best-effort */
695+ }
696+ if ( code === 0 ) {
697+ emit ( "\nInstallation complete!\n" ) ;
698+ resolve ( ) ;
699+ return ;
700+ }
701+ // Same tolerance as the bash path: if the binary tree exists, count it.
702+ if ( existsSync ( HERMES_PYTHON ) && existsSync ( HERMES_SCRIPT ) ) {
703+ emit (
704+ "\nInstall script exited with warnings, but Hermes is installed successfully.\n" ,
705+ ) ;
706+ resolve ( ) ;
707+ } else {
708+ reject (
709+ new Error (
710+ `Installation failed (exit code ${ code } ). Open PowerShell and try: irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex` ,
711+ ) ,
712+ ) ;
713+ }
714+ } ) ;
715+
716+ proc . on ( "error" , ( err ) => {
717+ try {
718+ unlinkSync ( wrapperPath ) ;
719+ } catch {
720+ /* best-effort */
721+ }
722+ // Most common failure: PowerShell is missing or blocked by policy.
723+ const hint =
724+ ( err as NodeJS . ErrnoException ) . code === "ENOENT"
725+ ? " PowerShell was not found. Reinstall Windows PowerShell or run the installer manually from a terminal."
726+ : "" ;
727+ reject ( new Error ( `Failed to start installer: ${ err . message } .${ hint } ` ) ) ;
728+ } ) ;
729+ } ) ;
730+ }
731+
566732// ────────────────────────────────────────────────────
567733// Backup & Import
568734// ────────────────────────────────────────────────────
0 commit comments