Skip to content

Commit e2f544a

Browse files
committed
Windows Support
1 parent 1d1d2d5 commit e2f544a

2 files changed

Lines changed: 353 additions & 152 deletions

File tree

src/main/installer.ts

Lines changed: 201 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { 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";
56
import type { BrowserWindow } from "electron";
67
import { getModelConfig, getConnectionConfig } from "./config";
78
import { stripAnsi } from "./utils";
89
import { setupAskpass, AskpassHandle } from "./askpass";
910

11+
const IS_WINDOWS = process.platform === "win32";
12+
1013
export const HERMES_HOME =
1114
process.env.HERMES_HOME?.trim() || join(homedir(), ".hermes");
1215
export const HERMES_REPO = join(HERMES_HOME, "hermes-agent");
1316
export 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");
1520
export const HERMES_SCRIPT = join(HERMES_REPO, "hermes");
1621
export const HERMES_ENV_FILE = join(HERMES_HOME, ".env");
1722
export const HERMES_CONFIG_FILE = join(HERMES_HOME, "config.yaml");
@@ -34,21 +39,34 @@ export interface InstallProgress {
3439

3540
export 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.
417436
const STAGE_MARKERS: { pattern: RegExp; step: number; title: string }[] = [
418437
{
419-
pattern: /Checking for (git|uv|python)/i,
438+
pattern: /Checking (for )?(git|uv|python|node|ripgrep|ffmpeg)/i,
420439
step: 1,
421440
title: "Checking prerequisites",
422441
},
423442
{
424-
pattern: /Installing uv|uv found/i,
443+
pattern: /Installing uv|uv found|uv installed/i,
425444
step: 2,
426445
title: "Setting up package manager",
427446
},
428447
{
429-
pattern: /Installing Python|Python .* found/i,
448+
pattern: /Installing Python|Python .* found|Python installed/i,
430449
step: 3,
431450
title: "Setting up Python",
432451
},
433452
{
434-
pattern: /Cloning|cloning|Updating.*repository|Repository/i,
453+
pattern:
454+
/Cloning|cloning|Updating.*repository|Repository|Installing to .*hermes-agent|Downloading PortableGit/i,
435455
step: 4,
436456
title: "Downloading Hermes Agent",
437457
},
438458
{
439-
pattern: /Creating virtual|virtual environment|venv/i,
459+
pattern: /Creating virtual|virtual environment|uv venv|\bvenv\b/i,
440460
step: 5,
441461
title: "Creating Python environment",
442462
},
443463
{
444-
pattern: /pip install|Installing.*packages|dependencies/i,
464+
pattern:
465+
/pip install|Installing.*packages|dependencies|Trying tier|Resolving|Main package installed/i,
445466
step: 6,
446467
title: "Installing dependencies",
447468
},
448469
{
449-
pattern: /Configuration|config|Setup complete|Installation complete/i,
470+
pattern:
471+
/Configuration|config|Setup complete|Installation complete|Configuration directory ready|hermes command ready|All dependencies installed/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

Comments
 (0)