From e3a254d65b1b83d9ee75d4591113fa65a7f3a13d Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 18 May 2026 16:34:24 +0530 Subject: [PATCH] =?UTF-8?q?feat(dep=5Fensure):=20complete=20Windows=20boot?= =?UTF-8?q?strap=20=E2=80=94=20dep=5Fensure=20+=20install.ps1=20+=20detect?= =?UTF-8?q?ion=20(#27845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dep_ensure): complete Windows bootstrap — dep_ensure + install.ps1 + detection dep_ensure.py gains Windows awareness: PowerShell invocation, platform- specific browser detection, (path, shell) tuple returns. install.ps1 gains -Ensure/-PostInstall modes using npm -g --prefix (aligned with install.sh) and agent-browser install for Chromium. browser_tool.py gains node/ in candidate dirs for Windows .cmd shims. Both install scripts bundled in pip wheel. Tracking: #27826 * fix(install.ps1): add --ignore-scripts to npm install for camofox @askjo/camofox-browser has a dependency (impit) whose postinstall script runs `npx only-allow pnpm`, which fails under npm. Adding --ignore-scripts avoids the spurious failure without affecting functionality. Tracking: #27826 * fix: remove duplicate install scripts from git CI already copies scripts/install.{sh,ps1} into hermes_cli/scripts/ during wheel build. No need to commit copies — .gitignore keeps them out, _find_install_script() falls back to scripts/ for git-clone users. Tracking: #27826 * fix: address review — remove env_extra, fix ps1 error handling - Remove unused env_extra parameter from ensure_dependency() - Invoke-EnsureMode node case now uses Test-Node consistently - Install-AgentBrowser uses throw instead of exit 1 --- .github/workflows/upload_to_pypi.yml | 3 +- hermes_cli/dep_ensure.py | 89 ++++++++++++--- pyproject.toml | 2 +- scripts/install.ps1 | 160 ++++++++++++++++++++++++++- tests/hermes_cli/test_dep_ensure.py | 134 ++++++++++++++++++++-- tools/browser_tool.py | 9 +- 6 files changed, 368 insertions(+), 29 deletions(-) diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 95477ccf01f..86e7ae477b3 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -71,10 +71,11 @@ jobs: test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } - - name: Bundle install.sh into wheel + - name: Bundle install scripts into wheel run: | mkdir -p hermes_cli/scripts cp scripts/install.sh hermes_cli/scripts/install.sh + cp scripts/install.ps1 hermes_cli/scripts/install.ps1 - name: Build wheel and sdist run: uv build --sdist --wheel diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index 1067b428f7b..848e402396c 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -16,11 +16,14 @@ browser tool needs agent-browser). from __future__ import annotations import os +import platform import shutil import subprocess import sys from pathlib import Path +_IS_WINDOWS = platform.system() == "Windows" + _DEP_CHECKS = { "node": lambda: shutil.which("node") is not None, "browser": lambda: ( @@ -41,7 +44,11 @@ _DEP_DESCRIPTIONS = { def _has_system_browser() -> bool: - for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"): + if _IS_WINDOWS: + names = ("chrome", "msedge", "chromium") + else: + names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") + for name in names: if shutil.which(name): return True return False @@ -49,39 +56,67 @@ def _has_system_browser() -> bool: def _has_hermes_agent_browser() -> bool: from hermes_constants import get_hermes_home - return (get_hermes_home() / "node_modules" / ".bin" / "agent-browser").is_file() + home = get_hermes_home() + if _IS_WINDOWS: + # npm -g --prefix puts .cmd shims directly in the prefix dir on Windows + return (home / "node" / "agent-browser.cmd").is_file() + # install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix + # Also check legacy node_modules/.bin/ path for git-clone installs. + return ( + (home / "node" / "bin" / "agent-browser").is_file() + or (home / "node_modules" / ".bin" / "agent-browser").is_file() + ) def _find_install_script( package_dir: Path | None = None, repo_root: Path | None = None, -) -> Path | None: - """Locate install.sh — bundled in wheel or in git checkout.""" +) -> tuple[Path | None, str | None]: + """Locate the install script — bundled in wheel or in git checkout. + + On Windows, prefers install.ps1; on POSIX, prefers install.sh. + Returns a (path, shell) tuple, or (None, None) if neither is found. + """ if package_dir is None: package_dir = Path(__file__).parent if repo_root is None: repo_root = package_dir.parent - bundled = package_dir / "scripts" / "install.sh" - if bundled.is_file(): - return bundled - repo = repo_root / "scripts" / "install.sh" - if repo.is_file(): - return repo - return None + if _IS_WINDOWS: + preferred = ("install.ps1", "powershell") + fallback = ("install.sh", "bash") + else: + preferred = ("install.sh", "bash") + fallback = ("install.ps1", "powershell") + + for script_name, shell in (preferred, fallback): + bundled = package_dir / "scripts" / script_name + if bundled.is_file(): + return bundled, shell + repo = repo_root / "scripts" / script_name + if repo.is_file(): + return repo, shell + + return None, None -def ensure_dependency(dep: str, interactive: bool = True) -> bool: +def ensure_dependency( + dep: str, + interactive: bool = True, +) -> bool: """Ensure a non-Python dependency is available. Returns True if available.""" check = _DEP_CHECKS.get(dep) - if check and check(): + if check is None: + # Unknown dep — don't silently forward to install script. + return False + if check(): return True - script = _find_install_script() + script, shell = _find_install_script() if script is None: if interactive: desc = _DEP_DESCRIPTIONS.get(dep, dep) - print(f" {desc} is not installed and install.sh was not found.") + print(f" {desc} is not installed and no install script was found.") print(f" Install {dep} manually and try again.") return False @@ -91,12 +126,30 @@ def ensure_dependency(dep: str, interactive: bool = True) -> bool: reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): return False - if reply not in {"", "y", "yes"}: + if reply not in ("", "y", "yes"): return False + if shell == "powershell": + from hermes_constants import get_hermes_home + ps_bin = shutil.which("powershell") or shutil.which("pwsh") + if not ps_bin: + if interactive: + print(" PowerShell not found. Install PowerShell or run install.ps1 manually.") + return False + cmd = [ + ps_bin, + "-ExecutionPolicy", "Bypass", + "-File", str(script), + "-Ensure", dep, + "-HermesHome", str(get_hermes_home()), + ] + else: + cmd = ["bash", str(script), "--ensure", dep] + + run_env = {**os.environ, "IS_INTERACTIVE": "false"} result = subprocess.run( - ["bash", str(script), "--ensure", dep], - env={**os.environ, "IS_INTERACTIVE": "false"}, + cmd, + env=run_env, ) if result.returncode != 0: return False diff --git a/pyproject.toml b/pyproject.toml index ba66d0da719..cb3c515e021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,7 +210,7 @@ hermes-acp = "acp_adapter.entry:main" py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*"] +hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] gateway = ["assets/**/*"] [tool.setuptools.packages.find] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c774e9a860c..7fb618eca6e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -28,7 +28,11 @@ param( [string]$Stage, [switch]$ProtocolVersion, [switch]$NonInteractive, - [switch]$Json + [switch]$Json, + + # --- Ensure mode (dep_ensure.py entry point) --- + [string]$Ensure = "", + [switch]$PostInstall ) $ErrorActionPreference = "Stop" @@ -108,6 +112,105 @@ function Write-Err { Write-Host "[X] $Message" -ForegroundColor Red } +# --- Ensure-mode helpers --- + +function Resolve-NpmCmd { + $npmCmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npmCmd) { return $null } + $npmExe = $npmCmd.Source + if ($npmExe -like "*.ps1") { + $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $npmCmdSibling) { return $npmCmdSibling } + } + return $npmExe +} + +function Find-SystemBrowser { + $candidates = @( + "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles}\Chromium\Application\chrome.exe", + "${env:LOCALAPPDATA}\Chromium\Application\chrome.exe" + ) + foreach ($p in $candidates) { + if (Test-Path $p) { return $p } + } + return $null +} + +function Write-BrowserEnv { + param([string]$BrowserPath) + if (-not (Test-Path $HermesHome)) { + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + } + $envFile = Join-Path $HermesHome ".env" + if (-not (Test-Path $envFile)) { + Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 + return + } + $content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue + if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return } + Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 +} + +function Install-AgentBrowser { + param([switch]$SkipChromium) + $npm = Resolve-NpmCmd + if (-not $npm) { + Write-Err "npm not found -- install Node.js first" + throw "npm not found" + } + + Write-Info "Installing agent-browser via npm -g --prefix..." + $prefixDir = Join-Path $HermesHome "node" + if (-not (Test-Path $prefixDir)) { + New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null + } + $npmLog = [System.IO.Path]::GetTempFileName() + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npm install -g --prefix $prefixDir --silent --ignore-scripts "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null + $npmExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($npmExit -ne 0) { + $npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue + Remove-Item $npmLog -Force -ErrorAction SilentlyContinue + Write-Err "npm install -g failed (exit $npmExit): $npmDetail" + throw "npm install failed" + } + Remove-Item $npmLog -Force -ErrorAction SilentlyContinue + + if (-not $SkipChromium) { + $sysBrowser = Find-SystemBrowser + if ($sysBrowser) { + Write-BrowserEnv -BrowserPath $sysBrowser + Write-Info "System browser detected -- skipping Chromium download" + } else { + $abExe = Join-Path $prefixDir "agent-browser.cmd" + if (Test-Path $abExe) { + Write-Info "Installing Chromium via agent-browser install..." + $abLog = [System.IO.Path]::GetTempFileName() + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $abExe install 2>&1 | Tee-Object -FilePath $abLog | Out-Null + $abExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($abExit -ne 0) { + $abDetail = Get-Content $abLog -Raw -ErrorAction SilentlyContinue + Write-Warn "Chromium install failed (exit $abExit): $abDetail" + } + Remove-Item $abLog -Force -ErrorAction SilentlyContinue + } else { + Write-Warn "agent-browser.cmd not found at $abExe" + } + } + } + Write-Success "Agent-browser ready" +} + # ============================================================================ # Dependency checks # ============================================================================ @@ -2043,6 +2146,48 @@ function Invoke-AllStages { } } +function Invoke-EnsureMode { + param([string]$Deps) + $depList = $Deps -split "," + foreach ($dep in $depList) { + $dep = $dep.Trim() + switch ($dep) { + "node" { + [void](Test-Node) + if (-not $script:HasNode) { + Write-Err "Node.js could not be installed" + exit 1 + } + } + "browser" { + [void](Test-Node) + if ($script:HasNode) { + Install-AgentBrowser + } else { + Write-Err "Node.js is required for browser tools but could not be installed" + exit 1 + } + } + "ripgrep" { + Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)" + } + "ffmpeg" { + Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)" + } + default { + Write-Err "Unknown dependency: $dep" + exit 1 + } + } + } +} + +function Invoke-PostInstallMode { + Write-Info "Running post-install setup..." + Invoke-EnsureMode -Deps "node,browser" + Write-Info "Post-install complete" +} + function Main { Write-Banner Invoke-AllStages @@ -2062,6 +2207,19 @@ function Main { # structured JSON error frame instead of a bare exception. try { + if ($Ensure -ne "") { + if ($PSBoundParameters.ContainsKey("Stage")) { + Write-Err "Cannot use -Ensure and -Stage simultaneously" + exit 1 + } + Invoke-EnsureMode -Deps $Ensure + exit 0 + } + if ($PostInstall) { + Invoke-PostInstallMode + exit 0 + } + if ($ProtocolVersion) { Write-Output $InstallStageProtocolVersion exit 0 diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py index c980c290099..77fee5b7ec5 100644 --- a/tests/hermes_cli/test_dep_ensure.py +++ b/tests/hermes_cli/test_dep_ensure.py @@ -16,7 +16,7 @@ def test_ensure_dependency_returns_false_when_missing_noninteractive(): from hermes_cli.dep_ensure import ensure_dependency with patch("hermes_cli.dep_ensure.shutil") as mock_shutil: mock_shutil.which.return_value = None - with patch("hermes_cli.dep_ensure._find_install_script", return_value=None): + with patch("hermes_cli.dep_ensure._find_install_script", return_value=(None, None)): result = ensure_dependency("node", interactive=False) assert result is False @@ -27,9 +27,11 @@ def test_find_install_script_from_checkout(tmp_path): scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() (scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert result is not None - assert result.name == "install.sh" + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert path is not None + assert path.name == "install.sh" + assert shell == "bash" def test_find_install_script_from_wheel(tmp_path): @@ -38,6 +40,124 @@ def test_find_install_script_from_wheel(tmp_path): bundled = tmp_path / "hermes_cli" / "scripts" bundled.mkdir(parents=True) (bundled / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert result is not None - assert result.name == "install.sh" + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert path is not None + assert path.name == "install.sh" + assert shell == "bash" + + +def test_find_install_script_prefers_ps1_on_windows(tmp_path): + """On Windows, _find_install_script should find install.ps1.""" + scripts_dir = tmp_path / "hermes_cli" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + (scripts_dir / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli") + assert path == scripts_dir / "install.ps1" + assert shell == "powershell" + + +def test_find_install_script_returns_sh_on_posix(tmp_path): + """On POSIX, _find_install_script should find install.sh.""" + scripts_dir = tmp_path / "hermes_cli" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + (scripts_dir / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli") + assert path == scripts_dir / "install.sh" + assert shell == "bash" + + +def test_find_install_script_falls_back_to_repo_root(tmp_path): + """When no bundled script, check repo root.""" + repo_root = tmp_path / "repo" + (repo_root / "scripts").mkdir(parents=True) + (repo_root / "scripts" / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=repo_root) + assert path == repo_root / "scripts" / "install.sh" + assert shell == "bash" + + +def test_find_install_script_returns_none_when_missing(tmp_path): + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + result = _find_install_script(package_dir=tmp_path / "x", repo_root=tmp_path / "y") + assert result == (None, None) + + +def test_has_system_browser_checks_windows_names(): + from hermes_cli.dep_ensure import _has_system_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.side_effect = lambda name: "/fake/msedge.exe" if name == "msedge" else None + assert _has_system_browser() is True + + +def test_has_system_browser_checks_posix_names(): + from hermes_cli.dep_ensure import _has_system_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.return_value = None + assert _has_system_browser() is False + + +def test_has_hermes_agent_browser_windows_path(tmp_path): + node_dir = tmp_path / "node" + node_dir.mkdir(parents=True) + (node_dir / "agent-browser.cmd").write_text("@echo off") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_has_hermes_agent_browser_posix_path(tmp_path): + bin_dir = tmp_path / "node" / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "agent-browser").write_text("#!/bin/sh") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_has_hermes_agent_browser_legacy_node_modules_path(tmp_path): + """Legacy git-clone installs put agent-browser in $HERMES_HOME/node_modules/.bin/.""" + bin_dir = tmp_path / "node_modules" / ".bin" + bin_dir.mkdir(parents=True) + (bin_dir / "agent-browser").write_text("#!/bin/sh") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_ensure_dependency_uses_powershell_on_windows(tmp_path): + from hermes_cli.dep_ensure import ensure_dependency + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_cli.dep_ensure._DEP_CHECKS", {"node": lambda: False}), \ + patch("hermes_cli.dep_ensure._find_install_script", return_value=(scripts_dir / "install.ps1", "powershell")), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil, \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path / "fakehome"), \ + patch("subprocess.run") as mock_run, \ + patch("sys.stdin") as mock_stdin: + mock_shutil.which.side_effect = lambda name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" if name == "powershell" else None + mock_stdin.isatty.return_value = False + mock_run.return_value = type("R", (), {"returncode": 0})() + ensure_dependency("node", interactive=False) + cmd = mock_run.call_args[0][0] + assert "powershell" in cmd[0].lower() + assert "-Ensure" in cmd + assert cmd[cmd.index("-Ensure") + 1] == "node" + assert "-HermesHome" in cmd + assert str(tmp_path / "fakehome") in cmd diff --git a/tools/browser_tool.py b/tools/browser_tool.py index fb96649cb38..447f6500714 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -158,8 +158,9 @@ def _browser_candidate_path_dirs() -> list[str]: """Return ordered browser CLI PATH candidates shared by discovery and execution.""" hermes_home = get_hermes_home() hermes_node_bin = str(hermes_home / "node" / "bin") + hermes_node_root = str(hermes_home / "node") hermes_nm_bin = str(hermes_home / "node_modules" / ".bin") - return [hermes_node_bin, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] + return [hermes_node_bin, hermes_node_root, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] def _merge_browser_path(existing_path: str = "") -> str: @@ -1827,6 +1828,12 @@ def _find_agent_browser() -> str: if not recheck: hermes_nm = str(get_hermes_home() / "node_modules" / ".bin") recheck = shutil.which("agent-browser", path=hermes_nm) + if not recheck: + hermes_node_bin = str(get_hermes_home() / "node" / "bin") + recheck = shutil.which("agent-browser", path=hermes_node_bin) + if not recheck: + hermes_node_root = str(get_hermes_home() / "node") + recheck = shutil.which("agent-browser", path=hermes_node_root) if recheck: _cached_agent_browser = recheck _agent_browser_resolved = True