From c4b9750bc1d335ac2d13cead1f8926ab5663e79b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 10:47:37 -0500 Subject: [PATCH] feat: lazy bootstrap node --- README.md | 9 +- hermes_cli/main.py | 58 ++++++++- scripts/lib/node-bootstrap.sh | 238 ++++++++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/node-bootstrap.sh diff --git a/README.md b/README.md index 07a140419..ab158fc2b 100644 --- a/README.md +++ b/README.md @@ -141,11 +141,18 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process. -Quick start for contributors: +Quick start for contributors — clone and go with `setup-hermes.sh`: ```bash git clone https://github.com/NousResearch/hermes-agent.git cd hermes-agent +./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes +./hermes # auto-detects the venv, no need to `source` first +``` + +Manual path (equivalent to the above): + +```bash curl -LsSf https://astral.sh/uv/install.sh | sh uv venv venv --python 3.11 source venv/bin/activate diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 57926b1ce..a9ad31187 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -789,8 +789,63 @@ def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: return False +def _ensure_tui_node() -> None: + """Make sure `node` + `npm` are on PATH for the TUI. + + If either is missing and scripts/lib/node-bootstrap.sh is available, source + it and call `ensure_node` (fnm/nvm/proto/brew/bundled cascade). After + install, capture the resolved node binary path from the bash subprocess + and prepend its directory to os.environ["PATH"] so shutil.which finds the + new binaries in this Python process — regardless of which version manager + was used (nvm, fnm, proto, brew, or the bundled fallback). + + Idempotent no-op when node+npm are already discoverable. Set + ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. + """ + if shutil.which("node") and shutil.which("npm"): + return + if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): + return + + helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + if not helper.is_file(): + return + + hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") + try: + # Helper writes logs to stderr; we ask bash to print `command -v node` + # on stdout once ensure_node succeeds. Subshell PATH edits don't leak + # back into Python, so the stdout capture is the bridge. + result = subprocess.run( + ["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'], + env={**os.environ, "HERMES_HOME": hermes_home}, + capture_output=True, + text=True, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return + + parts = os.environ.get("PATH", "").split(os.pathsep) + extras: list[Path] = [] + + resolved = (result.stdout or "").strip() + if resolved: + extras.append(Path(resolved).resolve().parent) + + extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]) + + for extra in extras: + s = str(extra) + if extra.is_dir() and s not in parts: + parts.insert(0, s) + os.environ["PATH"] = os.pathsep.join(parts) + + def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """Ink TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" + _ensure_tui_node() + def _node_bin(bin: str)-> str: path = shutil.which(bin) if not path: @@ -809,7 +864,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: npm = _node_bin("npm") if _tui_need_npm_install(tui_dir): - print("Installing TUI dependencies…") + if not os.environ.get("HERMES_QUIET"): + print("Installing TUI dependencies…") result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], cwd=str(tui_dir), diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh new file mode 100644 index 000000000..9eadc479d --- /dev/null +++ b/scripts/lib/node-bootstrap.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# ============================================================================ +# scripts/lib/node-bootstrap.sh +# ---------------------------------------------------------------------------- +# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI +# (React + Ink), browser tools, and the WhatsApp bridge. +# +# Strategy (first hit wins — respects the user's existing tooling): +# 1. modern `node` already on PATH +# 2. ~/.hermes/node/ from a prior Hermes-managed install +# 3. fnm, proto, nvm (in that order) if the user already uses a version manager +# 4. Termux `pkg`, macOS Homebrew +# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits) +# +# Usage: +# source scripts/lib/node-bootstrap.sh +# ensure_node # returns 0 on success, non-zero on failure +# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi +# +# Env inputs (set before sourcing to override defaults): +# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH +# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install +# HERMES_HOME (default: $HOME/.hermes) +# ============================================================================ + +HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}" +HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +HERMES_NODE_AVAILABLE=false + +# --------------------------------------------------------------------------- +# Logging — prefer the host script's log_* helpers when present +# --------------------------------------------------------------------------- + +_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; } +_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; } +_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; } + +# --------------------------------------------------------------------------- +# Platform + version helpers +# --------------------------------------------------------------------------- + +_nb_is_termux() { + [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] +} + +_nb_node_major() { + local v + v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0 +} + +_nb_have_modern_node() { + command -v node >/dev/null 2>&1 || return 1 + [ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ] +} + +# --------------------------------------------------------------------------- +# Version-manager paths — respect what the user already uses +# --------------------------------------------------------------------------- + +_nb_try_fnm() { + command -v fnm >/dev/null 2>&1 || return 1 + _nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..." + eval "$(fnm env 2>/dev/null)" || true + fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) activated via fnm" + return 0 +} + +_nb_try_proto() { + command -v proto >/dev/null 2>&1 || return 1 + _nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..." + proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) activated via proto" + return 0 +} + +_nb_try_nvm() { + local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh" + [ -s "$nvm_sh" ] || return 1 + # shellcheck source=/dev/null + \. "$nvm_sh" >/dev/null 2>&1 || return 1 + _nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..." + nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) activated via nvm" + return 0 +} + +# --------------------------------------------------------------------------- +# Platform package managers +# --------------------------------------------------------------------------- + +_nb_try_termux_pkg() { + _nb_is_termux || return 1 + _nb_log "Installing Node.js via pkg..." + pkg install -y nodejs >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) installed via pkg" + return 0 +} + +_nb_try_brew() { + [ "$(uname -s)" = "Darwin" ] || return 1 + command -v brew >/dev/null 2>&1 || return 1 + _nb_log "Installing Node via Homebrew..." + brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \ + || brew install node >/dev/null 2>&1 \ + || return 1 + brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) installed via Homebrew" + return 0 +} + +# --------------------------------------------------------------------------- +# Bundled binary fallback — always works, no shell rc edits +# --------------------------------------------------------------------------- + +_nb_install_bundled_node() { + local arch node_arch os_name node_os + arch=$(uname -m) + case "$arch" in + x86_64) node_arch="x64" ;; + aarch64|arm64) node_arch="arm64" ;; + armv7l) node_arch="armv7l" ;; + *) + _nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/" + return 1 + ;; + esac + + os_name=$(uname -s) + case "$os_name" in + Linux*) node_os="linux" ;; + Darwin*) node_os="darwin" ;; + *) + _nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/" + return 1 + ;; + esac + + local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/" + local tarball + tarball=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ + | head -1) + if [ -z "$tarball" ]; then + tarball=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \ + | head -1) + fi + if [ -z "$tarball" ]; then + _nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch" + return 1 + fi + + local tmp + tmp=$(mktemp -d) + _nb_log "Downloading $tarball..." + curl -fsSL "${index_url}${tarball}" -o "$tmp/$tarball" || { + _nb_warn "Download failed"; rm -rf "$tmp"; return 1 + } + + _nb_log "Extracting to $HERMES_HOME/node/..." + if [[ "$tarball" == *.tar.xz ]]; then + tar xf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; } + else + tar xzf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; } + fi + + local extracted + extracted=$(find "$tmp" -maxdepth 1 -type d -name 'node-v*' 2>/dev/null | head -1) + if [ ! -d "$extracted" ]; then + _nb_warn "Extraction produced no node-v* directory" + rm -rf "$tmp" + return 1 + fi + + mkdir -p "$HERMES_HOME" + rm -rf "$HERMES_HOME/node" + mv "$extracted" "$HERMES_HOME/node" + rm -rf "$tmp" + + mkdir -p "$HOME/.local/bin" + ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node" + ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm" + ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx" + export PATH="$HERMES_HOME/node/bin:$PATH" + + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) installed to $HERMES_HOME/node/" + return 0 +} + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +ensure_node() { + HERMES_NODE_AVAILABLE=false + + if _nb_have_modern_node; then + _nb_ok "Node $(node --version) found" + HERMES_NODE_AVAILABLE=true + return 0 + fi + + if [ -x "$HERMES_HOME/node/bin/node" ]; then + export PATH="$HERMES_HOME/node/bin:$PATH" + if _nb_have_modern_node; then + _nb_ok "Node $(node --version) found (Hermes-managed)" + HERMES_NODE_AVAILABLE=true + return 0 + fi + fi + + # Version managers first — respect the user's existing setup. + _nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; } + _nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; } + _nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; } + + # Platform package managers. + _nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; } + _nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; } + + # Last resort: pinned nodejs.org tarball. + _nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; } + + _nb_warn "Node.js install failed — TUI and browser tools will be unavailable." + _nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)" + return 1 +}