diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 3cc22574200..ad5112e7109 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -2,8 +2,15 @@ //! //! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI: //! Windows: %LOCALAPPDATA%\hermes -//! macOS: ~/Library/Application Support/hermes -//! Linux: ~/.hermes (XDG override via $HERMES_HOME) +//! macOS: ~/.hermes +//! Linux: ~/.hermes (override via $HERMES_HOME) +//! +//! NOTE (macOS): Python's get_hermes_home(), scripts/install.sh, and the +//! Electron desktop's resolveHermesHome() ALL use ~/.hermes on macOS — there +//! is no ~/Library/Application Support branch anywhere else. An earlier +//! version of this file used Application Support, which drifted from every +//! other component: the installer wrote the install to one dir and the +//! desktop looked for it in another, so first launch never found the backend. //! //! IMPORTANT: this must match exactly. Drift here means install.ps1 //! writes to one place and the installer reads from another, breaking @@ -28,15 +35,8 @@ pub fn hermes_home() -> PathBuf { } } - #[cfg(target_os = "macos")] - { - // ~/Library/Application Support/hermes - if let Some(home) = dirs::home_dir() { - return home.join("Library/Application Support/hermes"); - } - } - - // Linux + fallback: ~/.hermes + // macOS + Linux + fallback: ~/.hermes (matches Python get_hermes_home(), + // install.sh, and the Electron desktop's resolveHermesHome()). if let Some(home) = dirs::home_dir() { return home.join(".hermes"); } diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 150f0cfe9b7..f5f6a376d33 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1127,6 +1127,15 @@ async function applyUpdates(opts = {}) { try { const updater = resolveUpdaterBinary() + if (!updater && !IS_WINDOWS) { + // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows + // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), + // there's no mandatory file locking here, so the desktop can drive the + // whole update itself: `hermes update` (backend) + `hermes desktop + // --build-only` (OS-aware GUI rebuild), then swap the running .app bundle + // with the freshly built one and relaunch. + return await applyUpdatesPosixInApp(opts) + } if (!updater) { // No staged updater binary — this is a CLI-installed user (they ran // `hermes desktop`, never the Tauri installer that self-copies @@ -1178,6 +1187,174 @@ async function applyUpdates(opts = {}) { } } +// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in +// the install we're updating, fall back to `hermes` on PATH. +function resolveHermesCliBinary(updateRoot) { + const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') + if (fileExists(venvHermes)) return venvHermes + return findOnPath('hermes') || null +} + +// Spawn a command and stream each output line to the update progress channel. +function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { + return new Promise(resolve => { + let child + try { + child = spawn(command, args, { + cwd, + env: { ...process.env, ...(env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (err) { + resolve({ code: 1, error: err.message }) + return + } + const emitLines = chunk => { + for (const line of chunk.toString().split('\n')) { + const trimmed = line.trim() + if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null }) + } + } + child.stdout.on('data', emitLines) + child.stderr.on('data', emitLines) + child.once('error', err => resolve({ code: 1, error: err.message })) + child.once('exit', code => resolve({ code })) + }) +} + +// The running app's .app bundle (packaged macOS): execPath is +// .app/Contents/MacOS/; climb three levels to the bundle root. +function runningAppBundle() { + if (!IS_MAC) return null + let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS + for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app + return dir.endsWith('.app') ? dir : null +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +// macOS/Linux in-app update: backend (`hermes update`) + OS-aware GUI rebuild +// (`hermes desktop --build-only`), then atomically swap the running .app bundle +// with the freshly built one and relaunch. Degrades to "backend updated, +// restart to load the new GUI" if the swap can't be performed. +async function applyUpdatesPosixInApp(opts = {}) { + const updateRoot = resolveUpdateRoot() + const hermes = resolveHermesCliBinary(updateRoot) + if (!hermes) { + emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) + return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } + } + + // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s + // npm build can find them on a machine with no system Node. + const extraPath = [path.join(HERMES_HOME, 'node', 'bin'), path.join(updateRoot, 'venv', 'bin')] + .filter(Boolean) + .join(path.delimiter) + const env = { + HERMES_HOME, + PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter) + } + + // Branch-pin so a non-main checkout doesn't get switched to main. + let branchArgs = [] + try { + const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) + const branch = (head.stdout || '').trim() + if (head.code === 0 && branch && branch !== 'HEAD') branchArgs = ['--branch', branch] + } catch { + // best effort + } + + emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) + const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { + cwd: updateRoot, + env, + stage: 'update' + }) + if (updated.code !== 0) { + emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) + return { ok: false, error: 'hermes update failed' } + } + + emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) + const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], { + cwd: updateRoot, + env, + stage: 'rebuild' + }) + if (rebuilt.code !== 0) { + emitUpdateProgress({ + stage: 'error', + message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.', + error: rebuilt.error || 'rebuild-failed' + }) + return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' } + } + + const rebuiltApp = [ + path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'), + path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app') + ].find(directoryExists) + const targetApp = runningAppBundle() + + // No bundle to swap (dev run, Linux AppImage, or unresolved paths): the + // backend is updated; the next launch picks up the rebuilt GUI. + if (!rebuiltApp || !targetApp) { + emitUpdateProgress({ + stage: 'done', + message: 'Backend updated. Restart Hermes to load the new version.', + percent: 100 + }) + return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } + } + + emitUpdateProgress({ stage: 'restart', message: 'Installing the updated app and restarting…', percent: 95 }) + + // Detached swapper: wait for THIS process to exit (so the bundle is free), + // ditto the rebuilt app over the running one, clear quarantine, relaunch. + const swapScript = `#!/bin/bash +set -u +APP_PID=${process.pid} +SRC=${shellQuote(rebuiltApp)} +DST=${shellQuote(targetApp)} +for _ in $(seq 1 240); do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 0.5 +done +if [ "$SRC" != "$DST" ]; then + if /usr/bin/ditto "$SRC" "$DST.hermes-update-new"; then + rm -rf "$DST.hermes-update-old" 2>/dev/null || true + mv "$DST" "$DST.hermes-update-old" 2>/dev/null || rm -rf "$DST" + mv "$DST.hermes-update-new" "$DST" + rm -rf "$DST.hermes-update-old" 2>/dev/null || true + fi +fi +/usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true +/usr/bin/open "$DST" +` + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { + fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 }) + } catch (err) { + emitUpdateProgress({ + stage: 'done', + message: 'Backend + app updated. Restart Hermes to load the new version.', + percent: 100 + }) + rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) + return { ok: true, backendUpdated: true, rebuiltApp } + } + + const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) + child.unref() + rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`) + + setTimeout(() => app.quit(), 600) + return { ok: true, handedOff: true, rebuiltApp, targetApp } +} + function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4211a73dd8e..16b3a7444a7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9119,6 +9119,43 @@ def _run_pre_update_backup(args) -> None: print() +def _discard_lockfile_churn(git_cmd, repo_root): + """Restore tracked ``package-lock.json`` files that npm dirtied locally. + + npm rewrites lockfiles non-deterministically at install/build time. On a + managed install those diffs are never intentional, so we discard them so + ``hermes update`` sees a clean tree instead of autostashing every run. + Best-effort; only ever touches files named ``package-lock.json``. + """ + try: + diff = subprocess.run( + git_cmd + ["diff", "--name-only"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if diff.returncode != 0: + return + dirty = [ + line.strip() + for line in diff.stdout.splitlines() + if line.strip().endswith("package-lock.json") + ] + if not dirty: + return + subprocess.run( + git_cmd + ["checkout", "--", *dirty], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))") + except Exception: + # Never let lockfile cleanup block an update. + pass + + def cmd_update(args): """Update Hermes Agent to the latest version. @@ -9296,6 +9333,15 @@ def _cmd_update_impl(args, gateway_mode: bool): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] + # Discard npm lockfile churn before any stash/branch logic. npm rewrites + # tracked package-lock.json files non-deterministically at install/build + # time (platform-specific optional deps, ideallyInert annotations, etc.), + # which is never an intentional edit on a managed install but leaves the + # tree dirty — forcing an autostash on every update and making branch + # switches fragile. Restoring them first lets the common case (only + # lockfile churn) update with a clean tree. + _discard_lockfile_churn(git_cmd, PROJECT_ROOT) + # Detect if we're updating from a fork (before any branch logic) origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) is_fork = _is_fork(origin_url) diff --git a/scripts/install.sh b/scripts/install.sh index 0006a1e543f..4b7d96a553e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -233,6 +233,23 @@ json_escape() { -e 's/"/\\"/g' } +# npm rewrites tracked package-lock.json files non-deterministically during +# `npm install` / `npm run pack`. On a managed install those diffs are never +# intentional, but they leave the checkout dirty — which forces `hermes update` +# to autostash on every run and makes branch switches fragile. Restore them so +# a fresh install ends with a clean tree. Best-effort; only touches lockfiles. +restore_dirty_lockfiles() { + local repo="${1:-$INSTALL_DIR}" + [ -n "$repo" ] && [ -d "$repo/.git" ] || return 0 + command -v git >/dev/null 2>&1 || return 0 + local dirty + dirty=$(git -C "$repo" diff --name-only 2>/dev/null | grep 'package-lock\.json$' || true) + [ -z "$dirty" ] && return 0 + echo "$dirty" | while IFS= read -r f; do + [ -n "$f" ] && git -C "$repo" checkout -- "$f" 2>/dev/null || true + done +} + emit_manifest() { # Stage-Desktop is included only with --include-desktop, mirroring # install.ps1: the signed bootstrap installer (Hermes-Setup) passes it so @@ -617,6 +634,73 @@ ensure_fts5() { fi } +# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git +# (which downloads PortableGit on Windows). git is required to clone the repo, +# and a fresh "normie" machine with no developer tools won't have it. Returns 0 +# if git is available afterwards, non-zero otherwise (caller prints manual +# instructions and aborts). +attempt_install_git() { + case "$OS" in + macos) + # Prefer Homebrew — fully headless when present. + if command -v brew >/dev/null 2>&1; then + log_info "Installing Git via Homebrew..." + brew install git >/dev/null 2>&1 || true + command -v git >/dev/null 2>&1 && return 0 + fi + # Fall back to Apple Command Line Tools, which provide git AND the + # compiler some Python wheels need. `xcode-select --install` pops a + # system dialog (Apple gates CLT behind it — it cannot be fully + # silent without MDM), so we trigger it and poll for git to appear. + if command -v xcode-select >/dev/null 2>&1; then + log_info "Requesting Apple Command Line Tools (provides git + compiler)..." + log_info "If a macOS dialog appears, click \"Install\" and accept the license." + xcode-select --install >/dev/null 2>&1 || true + local waited=0 + local timeout=900 + while [ "$waited" -lt "$timeout" ]; do + if command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; then + return 0 + fi + sleep 5 + waited=$((waited + 5)) + if [ $((waited % 60)) -eq 0 ]; then + log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..." + fi + done + fi + return 1 + ;; + linux) + local sudo_cmd="" + if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then + command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo" + fi + case "$DISTRO" in + ubuntu|debian) + log_info "Installing Git via apt..." + $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true + $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null 2>&1 || true + ;; + fedora) + log_info "Installing Git via dnf..." + $sudo_cmd dnf install -y git >/dev/null 2>&1 || true + ;; + arch) + log_info "Installing Git via pacman..." + $sudo_cmd pacman -S --noconfirm git >/dev/null 2>&1 || true + ;; + *) + return 1 + ;; + esac + command -v git >/dev/null 2>&1 && return 0 + return 1 + ;; + esac + return 1 +} + check_git() { log_info "Checking Git..." @@ -638,7 +722,15 @@ check_git() { fi fi - log_info "Please install Git:" + # Try to install it automatically before giving up (parity with install.ps1). + log_info "Attempting to install Git automatically..." + if attempt_install_git; then + GIT_VERSION=$(git --version | awk '{print $3}') + log_success "Git $GIT_VERSION installed" + return 0 + fi + + log_warn "Could not install Git automatically. Please install it manually:" case "$OS" in linux) @@ -1833,7 +1925,8 @@ install_node_deps() { log_success "TUI dependencies installed" fi - + # Keep the checkout clean so `hermes update` doesn't autostash every run. + restore_dirty_lockfiles "$INSTALL_DIR" } run_setup_wizard() { @@ -2232,6 +2325,10 @@ install_desktop() { return 1 fi log_success "Desktop app built: $app" + + # `npm install` + `npm run pack` rewrite lockfiles; restore them so the + # checkout stays clean for the next `hermes update`. + restore_dirty_lockfiles "$INSTALL_DIR" } # Each --stage runs in its own process, so (unlike the monolithic main() where @@ -2354,8 +2451,14 @@ run_stage_protocol() { return 0 fi + # Run the stage body in a subshell so a stage helper that calls `exit 1` + # on failure (clone_repo, install_deps, etc. were written for the monolithic + # flow) only exits the subshell — the parent still reaches the JSON result + # frame below. Without this, a failed --stage would terminate the process + # before emitting the frame and the Rust/Electron parser would see "no + # result frame" instead of a clean {ok:false} contract response. set +e - run_stage_body "$stage" + ( run_stage_body "$stage" ) local code=$? set -e