macOS desktop: install + in-app self-update (#35607)

* fix(installer): align macOS HERMES_HOME with the rest of the stack

paths.rs computed the macOS Hermes home as ~/Library/Application Support/
hermes, but nothing else does: hermes_constants.get_hermes_home() (Python),
scripts/install.sh, and the Electron desktop's resolveHermesHome() all use
~/.hermes on macOS. The drift meant the Tauri installer wrote the install to
one directory and the desktop looked for it in another, so a fresh GUI
install never found its backend (the file's own comment warned this exact
drift would break things). Use ~/.hermes on macOS to match.

* fix(install.sh): always emit a stage result frame on failure

Stage helpers (clone_repo, install_deps, check_python, …) were written for
the monolithic flow and call `exit 1` on failure. Under `--stage`, that
terminated the process before the JSON result frame was printed, so the
installer's parse_stage_result saw "no frame" instead of a clean
{ok:false,...} contract response. Run the stage body in a subshell so an
`exit` only unwinds the subshell and the parent still emits the frame.

* feat(install.sh): auto-provision git on macOS/Linux (parity with install.ps1)

install.ps1 downloads PortableGit on Windows, but install.sh just printed a
"please install git" hint and exited — so a fresh Mac with no developer tools
(no Xcode CLT → no git) couldn't get past the clone step. check_git now tries
to install git before bailing:
  - macOS: Homebrew if present (headless), else `xcode-select --install`
    (the CLT prompt also provides the compiler some wheels need), polling for
    git to appear.
  - Linux: apt/dnf/pacman via sudo when available.
Falls back to the manual instructions only if auto-provision fails.

* feat(desktop): in-app GUI+backend self-update on macOS/Linux

On Windows the staged Hermes-Setup binary drives updates (quit → hermes
update → hermes desktop --build-only → relaunch). The mac drag-install has no
such binary, so "Update now" previously just printed `hermes update`.

Since there's no venv-shim file lock on POSIX, the desktop can drive the whole
update itself. applyUpdates now, when no staged updater exists on mac/linux:
  1. runs `hermes update --yes [--branch <current>]` (backend git pull + deps),
  2. runs `hermes desktop --build-only` (OS-aware GUI rebuild) with the
     Hermes-managed Node + venv on PATH,
  3. spawns a detached swapper that waits for this process to exit, dittos the
     freshly built Hermes.app over the running bundle, clears quarantine, and
     relaunches.
Degrades to "backend updated — restart to load the new GUI" if the rebuild
fails or there's no .app bundle to swap (dev run, Linux AppImage).

* chore: uptick
This commit is contained in:
brooklyn! 2026-05-30 22:26:08 -05:00 committed by GitHub
parent dfc2fd887e
commit 5f9e0545ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 340 additions and 14 deletions

View file

@ -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");
}

View file

@ -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>.app/Contents/MacOS/<exe>; 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'))

View file

@ -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)

View file

@ -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