feat(installer): drive in-app updates through the Tauri installer

Converge update on the same principle as bootstrap: one driver owns all
repo mutation. The desktop becomes a pure consumer that hands off to
Hermes-Setup.exe --update instead of re-implementing git/pip in Electron.

- hermes desktop --build-only: build without launching, so the installer
  owns the post-update launch (CLI keeps build logic single-sourced).
- Installer AppMode {Install,Update} from argv; get_mode exposed to the UI.
- Installer self-copies to HERMES_HOME/hermes-setup.exe on install success
  (no-op guard during --update re-invocation to avoid the locked-exe copy).
- Installer --update flow (update.rs): wait for the desktop to release the
  venv shim, run 'hermes update --yes --gateway' (branch on exit 0/2/other),
  then 'hermes desktop --build-only', then launch the rebuilt desktop. Reuses
  the bootstrap event channel + progress UI via a synthetic two-stage manifest.
- Desktop applyUpdates() gutted (~105 lines of git/stash/pull/pyproject/pip
  removed) -> thin handoff: spawn updater, app.quit() to free the shim.
  Detection (checkUpdates, commit changelog, behind-count) kept intact.
- install.ps1 creates Start Menu + Desktop shortcuts to the packed Hermes.exe
  (never bare 'hermes desktop', which would rebuild every launch).
This commit is contained in:
emozilla 2026-05-28 23:48:21 -04:00
parent a4cfc8b740
commit 6381e70448
9 changed files with 709 additions and 128 deletions

View file

@ -539,6 +539,18 @@ async fn run_bootstrap(
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
// re-invoke us with `--update` and shortcuts have a stable target. This is
// a one-shot install concern; an `--update` re-invocation no-ops because
// we're already running from that path. Best-effort — a failure here must
// not fail an otherwise-successful install.
if let Err(err) = crate::paths::copy_self_to_hermes_home() {
tracing::warn!(?err, "failed to copy installer into HERMES_HOME (non-fatal)");
emit_log(&format!(
"[bootstrap] warning: could not stage updater binary: {err}"
));
}
emit_event(
&app,
BootstrapEvent::Complete {

View file

@ -13,10 +13,43 @@ mod events;
mod install_script;
mod powershell;
mod paths;
mod update;
use std::sync::Arc;
use tokio::sync::Mutex;
/// How the installer was invoked. Resolved once from the process args in
/// `run()` and exposed to the frontend via `get_mode` so it can route to the
/// install flow (first-run onboarding) or the update flow (driven by the
/// desktop app handing off via `Hermes-Setup.exe --update`).
///
/// Bare launch (double-click, first-run) => Install.
/// `--update` (spawned by the desktop's "Update" button) => Update.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AppMode {
Install,
Update,
}
impl AppMode {
/// Resolve the mode from an argument iterator. Anything containing the
/// `--update` flag selects Update; otherwise Install. Kept arg-iterator
/// generic (not reading `std::env` directly) so it's unit-testable.
pub fn from_args<I, S>(args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for a in args {
if a.as_ref() == "--update" {
return AppMode::Update;
}
}
AppMode::Install
}
}
/// Process-wide install state, shared across Tauri commands.
///
/// The bootstrap is a one-shot, single-tenant process — we only need one
@ -24,16 +57,26 @@ use tokio::sync::Mutex;
/// without lifetime gymnastics.
pub struct AppState {
pub bootstrap: Mutex<Option<bootstrap::BootstrapHandle>>,
/// How this process was launched (install vs update). Immutable for the
/// lifetime of the process; read by the `get_mode` command.
pub mode: AppMode,
}
impl Default for AppState {
fn default() -> Self {
impl AppState {
fn new(mode: AppMode) -> Self {
Self {
bootstrap: Mutex::new(None),
mode,
}
}
}
/// Frontend → Rust: which flow should the UI render?
#[tauri::command]
fn get_mode(state: tauri::State<'_, Arc<AppState>>) -> AppMode {
state.mode
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install
@ -41,19 +84,24 @@ pub fn run() {
// debug builds.
let _guard = paths::init_logging();
tracing::info!("Hermes Setup starting");
let mode = AppMode::from_args(std::env::args().skip(1));
tracing::info!(?mode, "Hermes Setup starting");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.manage(Arc::new(AppState::default()))
.manage(Arc::new(AppState::new(mode)))
.invoke_handler(tauri::generate_handler![
// Mode (install vs update)
get_mode,
// Bootstrap lifecycle
bootstrap::start_bootstrap,
bootstrap::cancel_bootstrap,
bootstrap::get_bootstrap_status,
// Update lifecycle
update::start_update,
// Hand-off
bootstrap::launch_hermes_desktop,
// Diagnostics
@ -64,3 +112,23 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running Hermes Setup");
}
#[cfg(test)]
mod tests {
use super::AppMode;
#[test]
fn bare_args_are_install() {
assert_eq!(AppMode::from_args(Vec::<String>::new()), AppMode::Install);
assert_eq!(AppMode::from_args(["--foo", "bar"]), AppMode::Install);
}
#[test]
fn update_flag_selects_update() {
assert_eq!(AppMode::from_args(["--update"]), AppMode::Update);
assert_eq!(
AppMode::from_args(["--something", "--update", "--else"]),
AppMode::Update
);
}
}

View file

@ -58,6 +58,55 @@ pub fn bootstrap_cache_dir() -> PathBuf {
hermes_home().join("bootstrap-cache")
}
/// Stable location the installer copies itself to after a successful install.
/// The desktop app re-invokes this with `--update`, and the start-menu /
/// desktop shortcuts can point users back to it. Lives directly under
/// HERMES_HOME so it survives repo checkout deletion (unlike anything under
/// hermes-agent/).
///
/// On Windows this is `%LOCALAPPDATA%\hermes\hermes-setup.exe`; on other
/// platforms the extension differs but the directory is the same.
pub fn installer_dest() -> PathBuf {
let name = if cfg!(target_os = "windows") {
"hermes-setup.exe"
} else {
"hermes-setup"
};
hermes_home().join(name)
}
/// Copy the currently-running installer binary to `installer_dest()` so it's
/// available for future `--update` runs and shortcut launches.
///
/// No-ops (returns Ok) when the running exe is ALREADY the destination — which
/// is exactly the case during an `--update` run (the desktop launched us FROM
/// that path), where copying onto ourselves would be a Windows sharing
/// violation. Best-effort: a failure here must not fail the install, so the
/// caller logs and continues.
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
let src = std::env::current_exe()?;
let dest = installer_dest();
// Skip if we're already running from the destination (update re-invocation
// or a prior copy). canonicalize both so symlinks / 8.3 short paths / case
// differences don't trick us into a self-copy.
let same = match (src.canonicalize(), dest.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => src == dest,
};
if same {
tracing::info!(?dest, "installer already at destination; skipping self-copy");
return Ok(());
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
Ok(())
}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')

View file

@ -0,0 +1,435 @@
//! Update orchestration.
//!
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
//! 4. launch the freshly-built desktop (reuses bootstrap::launch logic).
//!
//! We reuse the `BootstrapEvent` channel + the existing progress UI by
//! emitting a synthetic two-stage manifest ("update", "rebuild"). To the
//! frontend an update looks like a short bootstrap.
//!
//! Cross-platform note: `hermes update` already handles macOS/Linux (git/pip).
//! The only OS-specific bits here are the venv shim path (resolve_hermes) and
//! the no-window creation flag — both already cfg-gated. Keep new logic
//! OS-agnostic so the mac/linux port stays "fill in the paths".
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::events::{BootstrapEvent, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
/// Frontend → Rust: kick off the update flow. Mirrors `start_bootstrap`'s
/// fire-and-forget shape; progress arrives on the `bootstrap` event channel.
#[tauri::command]
pub async fn start_update(app: AppHandle) -> Result<(), String> {
tokio::spawn(async move {
if let Err(err) = run_update(app.clone()).await {
// run_update already emits a Failed event on the paths that matter;
// this catches anything that escaped. Emit defensively.
emit(
&app,
BootstrapEvent::Failed {
stage: None,
error: format!("{err:#}"),
},
);
}
});
Ok(())
}
async fn run_update(app: AppHandle) -> Result<()> {
let hermes_home = crate::paths::hermes_home();
let install_root = hermes_home.join("hermes-agent");
let hermes = resolve_hermes(&install_root).ok_or_else(|| {
let msg = format!(
"Could not find the hermes CLI under {}. Is Hermes installed? \
Re-run the installer to repair the install.",
install_root.display()
);
emit(
&app,
BootstrapEvent::Failed {
stage: None,
error: msg.clone(),
},
);
anyhow!(msg)
})?;
// Synthetic manifest so the existing progress UI renders our two stages.
emit(
&app,
BootstrapEvent::Manifest {
stages: vec![
stage_info("update", "Updating Hermes"),
stage_info("rebuild", "Rebuilding the desktop app"),
],
protocol_version: None,
},
);
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// ---- stage 1: hermes update -----------------------------------------
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
let update = run_streamed(
&app,
&hermes,
&["update", "--yes", "--gateway"],
&install_root,
Some("update"),
)
.await?;
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {
Some(0) => {
emit_stage(&app, "update", StageState::Succeeded, Some(update_ms), None);
}
Some(code) if code == UPDATE_EXIT_CONCURRENT => {
let msg = "Hermes is still running. Close all Hermes windows and try \
the update again."
.to_string();
emit_stage(
&app,
"update",
StageState::Failed,
Some(update_ms),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("update".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
other => {
let msg = format!(
"hermes update failed (exit {:?}). See {} for details.",
other,
crate::paths::hermes_home()
.join("logs")
.join("update.log")
.display()
);
emit_stage(
&app,
"update",
StageState::Failed,
Some(update_ms),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("update".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
}
// ---- stage 2: hermes desktop --build-only ----------------------------
// `hermes update` deliberately does NOT build apps/desktop (it installs
// repo-root deps with --workspaces=false). This is the rebuild it skips.
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild = run_streamed(
&app,
&hermes,
&["desktop", "--build-only"],
&install_root,
Some("rebuild"),
)
.await?;
let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) {
let msg = format!(
"Rebuilding the desktop app failed (exit {:?}). The update was \
applied but the app could not be rebuilt; run `hermes desktop` \
from a terminal to see the error.",
rebuild.exit_code
);
emit_stage(
&app,
"rebuild",
StageState::Failed,
Some(rebuild_ms),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("rebuild".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None);
// ---- done: signal complete, then launch the fresh desktop ------------
emit(
&app,
BootstrapEvent::Complete {
install_root: install_root.to_string_lossy().into_owned(),
marker: None,
},
);
// Reuse the same detached-launch + app.exit(0) used post-install.
if let Err(err) =
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned())
.await
{
// Launch failed: don't hard-fail the update (it succeeded); surface a
// log line so the success screen can still tell the user to launch
// manually.
emit_log(
&app,
None,
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
);
}
Ok(())
}
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
return;
}
if Instant::now() >= deadline {
emit_log(
app,
Some("update"),
"[update] timed out waiting for Hermes to exit; proceeding anyway",
);
return;
}
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
}
}
/// Best-effort lock probe: try to open the file for read+write. On Windows an
/// exclusively-held running .exe refuses the open with a sharing violation.
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
/// the venv-shim contention is a Windows-only problem.
fn is_locked(path: &Path) -> bool {
if !path.exists() {
return false;
}
match std::fs::OpenOptions::new().read(true).write(true).open(path) {
Ok(_) => false,
Err(_) => true,
}
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping).
async fn run_streamed(
app: &AppHandle,
program: &Path,
args: &[&str],
cwd: &Path,
stage: Option<&str>,
) -> Result<CmdResult> {
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW = 0x08000000 — no flashing console behind the GUI.
cmd.creation_flags(0x0800_0000);
}
let mut child = cmd
.spawn()
.map_err(|e| anyhow!("spawning {} {:?}: {e}", program.display(), args))?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let mut out = BufReader::new(stdout).lines();
let mut err = BufReader::new(stderr).lines();
let stage_owned = stage.map(|s| s.to_string());
loop {
tokio::select! {
line = out.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
Ok(None) => break,
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
},
line = err.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
Ok(None) => {}
Err(e) => { tracing::warn!("stderr read error: {e}"); }
},
}
}
while let Ok(Some(l)) = out.next_line().await {
emit_log(app, stage_owned.as_deref(), &l);
}
while let Ok(Some(l)) = err.next_line().await {
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
}
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
Ok(CmdResult {
exit_code: status.code(),
})
}
struct CmdResult {
exit_code: Option<i32>,
}
/// Path to the venv hermes shim under an install root, regardless of existence.
fn venv_hermes(install_root: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
install_root.join("venv").join("Scripts").join("hermes.exe")
} else {
install_root.join("venv").join("bin").join("hermes")
}
}
/// Resolve the hermes CLI to drive. Prefer the venv shim in the install we
/// just updated; fall back to `hermes` on PATH.
fn resolve_hermes(install_root: &Path) -> Option<PathBuf> {
let shim = venv_hermes(install_root);
if shim.exists() {
return Some(shim);
}
// PATH fallback. which-style probe via env, kept dependency-free.
let exe = if cfg!(target_os = "windows") { "hermes.exe" } else { "hermes" };
if let Ok(path) = std::env::var("PATH") {
let sep = if cfg!(target_os = "windows") { ';' } else { ':' };
for dir in path.split(sep) {
let cand = Path::new(dir).join(exe);
if cand.exists() {
return Some(cand);
}
}
}
None
}
// ---------------------------------------------------------------------------
// Event helpers — keep emit shape identical to bootstrap.rs so the UI is reused
// ---------------------------------------------------------------------------
fn stage_info(name: &str, title: &str) -> StageInfo {
StageInfo {
name: name.to_string(),
title: title.to_string(),
category: "update".to_string(),
needs_user_input: false,
}
}
fn emit(app: &AppHandle, event: BootstrapEvent) {
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
tracing::warn!(?e, "failed to emit update event");
}
}
fn emit_stage(
app: &AppHandle,
name: &str,
state: StageState,
duration_ms: Option<u64>,
error: Option<String>,
) {
tracing::info!(stage = %name, ?state, ?duration_ms, ?error, "update stage");
emit(
app,
BootstrapEvent::Stage {
name: name.to_string(),
state,
duration_ms,
result: None,
error,
},
);
}
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
match stage {
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
None => tracing::info!(target: "bootstrap.log", "{line}"),
}
emit(
app,
BootstrapEvent::Log {
stage: stage.map(|s| s.to_string()),
line: line.to_string(),
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn venv_hermes_is_under_install_root() {
let root = Path::new("/x/hermes-agent");
let shim = venv_hermes(root);
assert!(shim.starts_with(root));
assert!(shim.to_string_lossy().contains("venv"));
}
#[test]
fn missing_file_is_not_locked() {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
}

View file

@ -62,7 +62,13 @@ const INITIAL: BootstrapStateModel = {
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
/// How the installer was launched, mirrored from src-tauri AppMode.
/// 'install' = first-run onboarding (bare launch). 'update' = driven by the
/// desktop app handing off via `Hermes-Setup.exe --update`.
export type AppMode = 'install' | 'update'
export const $route = atom<Route>('welcome')
export const $mode = atom<AppMode>('install')
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
export const $logPath = atom<string | null>(null)
export const $hermesHome = atom<string | null>(null)
@ -128,12 +134,14 @@ export async function initialize(): Promise<void> {
// Pull static info on mount for the diagnostics footer.
try {
const [logPath, hermesHome] = await Promise.all([
const [logPath, hermesHome, mode] = await Promise.all([
invoke<string>('get_log_path'),
invoke<string>('get_hermes_home')
invoke<string>('get_hermes_home'),
invoke<AppMode>('get_mode')
])
$logPath.set(logPath)
$hermesHome.set(hermesHome)
$mode.set(mode)
} catch (err) {
console.warn('failed to fetch installer paths', err)
}
@ -211,6 +219,13 @@ export async function initialize(): Promise<void> {
break
}
})
// Update mode is a hand-off, not a user-initiated flow: the desktop already
// exited and re-launched us as `--update`. Kick the update immediately so
// the user lands on progress, not a redundant "click to update" screen.
if ($mode.get() === 'update') {
void startUpdate()
}
}
// ---------------------------------------------------------------------------
@ -232,6 +247,15 @@ export async function startInstall(opts?: { branch?: string }): Promise<void> {
})
}
export async function startUpdate(): Promise<void> {
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
// there's no welcome click. Reset + jump straight to progress, then let the
// Rust side stream the synthetic update manifest.
$bootstrap.set(INITIAL)
$route.set('progress')
await invoke('start_update')
}
export async function cancelInstall(): Promise<void> {
await invoke('cancel_bootstrap')
}

View file

@ -888,28 +888,6 @@ function getVenvPython(venvRoot) {
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
}
function runProcess(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env || process.env,
shell: Boolean(options.shell),
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.on('data', rememberLog)
child.stderr.on('data', rememberLog)
child.once('error', reject)
child.once('exit', code => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${path.basename(command)} exited with code ${code}: ${recentHermesLog()}`))
}
})
})
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
@ -1051,108 +1029,65 @@ async function readCommitLog(cwd, branch) {
let updateInFlight = false
// Resolve the staged updater binary. The Tauri installer copies itself to
// HERMES_HOME/hermes-setup.exe on a successful install (see
// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns
// ALL repo mutation — running `hermes update` + rebuilding the desktop — so
// the desktop never touches its own bits while running. Returns null when the
// updater isn't staged (e.g. a dev/source run that never went through the
// installer); callers degrade gracefully.
function resolveUpdaterBinary() {
const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup'
const candidate = path.join(HERMES_HOME, name)
return fileExists(candidate) ? candidate : null
}
// applyUpdates — hand off to the installer's --update flow, then exit.
//
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
// itself (the old open-coded git dance lived here and drifted from
// `hermes update`). Instead we spawn the staged Hermes-Setup binary with
// --update and quit, so it can run `hermes update` (which refuses while we
// hold the venv shim) and rebuild the desktop with our exe already gone.
//
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
// only this apply action changed.
async function applyUpdates(opts = {}) {
if (updateInFlight) {
throw new Error('An update is already in progress.')
}
updateInFlight = true
const dirtyStrategy = opts.dirtyStrategy === 'force' || opts.dirtyStrategy === 'stash' ? opts.dirtyStrategy : 'abort'
try {
const updateRoot = resolveUpdateRoot()
const gitDir = path.join(updateRoot, '.git')
if (!directoryExists(gitDir)) {
const message = `${updateRoot} isn't a git checkout — cannot self-update.`
emitUpdateProgress({ stage: 'error', error: 'not-a-git-checkout', message })
const updater = resolveUpdaterBinary()
if (!updater) {
const message =
'The Hermes updater was not found. Re-run the Hermes installer to ' +
'enable in-app updates, or run `hermes update` from a terminal.'
emitUpdateProgress({ stage: 'error', error: 'no-updater', message })
throw new Error(message)
}
const { branch } = readDesktopUpdateConfig()
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
emitUpdateProgress({ stage: 'prepare', message: 'Checking working tree…', percent: 5 })
const dirtyResult = await runGit(['status', '--porcelain'], { cwd: updateRoot })
const isDirty = dirtyResult.stdout.trim().length > 0
let stashRef = null
if (isDirty) {
if (dirtyStrategy === 'abort') {
const message = 'Uncommitted changes detected. Choose how to handle them and try again.'
emitUpdateProgress({ stage: 'error', error: 'dirty-tree', message })
throw new Error(message)
}
if (dirtyStrategy === 'stash') {
emitUpdateProgress({ stage: 'prepare', message: 'Stashing local changes…', percent: 10 })
const stashed = await runGit(['stash', 'push', '-u', '-m', `hermes-desktop-auto-${Date.now()}`], {
cwd: updateRoot
})
if (stashed.code !== 0) {
const message = firstLine(stashed.stderr) || 'git stash failed.'
emitUpdateProgress({ stage: 'error', error: 'stash-failed', message })
throw new Error(message)
}
stashRef = 'stash@{0}'
}
// dirtyStrategy === 'force' → pull --ff-only will refuse if anything
// conflicts, surfacing a clean error rather than us guessing.
}
const pyprojectBefore = sha256OfFile(path.join(updateRoot, 'pyproject.toml'))
emitUpdateProgress({ stage: 'fetch', message: `Fetching origin/${branch}`, percent: 20 })
const fetched = await runGit(['fetch', 'origin', branch], { cwd: updateRoot })
if (fetched.code !== 0) {
const message = firstLine(fetched.stderr) || 'git fetch failed.'
emitUpdateProgress({ stage: 'error', error: 'fetch-failed', message })
throw new Error(message)
}
emitUpdateProgress({ stage: 'pull', message: `Fast-forward merging origin/${branch}`, percent: 45 })
const pulled = await runGit(['pull', '--ff-only', 'origin', branch], {
cwd: updateRoot,
onLine: (_stream, text) => {
const line = firstLine(text)
if (line) emitUpdateProgress({ stage: 'pull', message: line.slice(0, 200), percent: 50 })
}
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
const child = spawn(updater, ['--update'], {
detached: true,
stdio: 'ignore',
windowsHide: false
})
if (pulled.code !== 0) {
const message = firstLine(pulled.stderr || pulled.stdout) || 'git pull failed.'
if (stashRef) {
await runGit(['stash', 'pop'], { cwd: updateRoot }).catch(() => {})
}
emitUpdateProgress({ stage: 'error', error: 'pull-failed', message })
throw new Error(message)
}
child.unref()
if (stashRef) {
emitUpdateProgress({ stage: 'pull', message: 'Restoring stashed changes…', percent: 60 })
const popped = await runGit(['stash', 'pop'], { cwd: updateRoot })
if (popped.code !== 0) {
emitUpdateProgress({
stage: 'pull',
message: 'Stash pop had conflicts — your changes are preserved in `git stash list`.',
percent: 60
})
}
}
rememberLog(`[updates] launched updater: ${updater} --update; exiting desktop to release venv shim`)
// findPythonForRoot picks the venv beside the resolved checkout (.venv or
// venv), matching how the backend discovers its Python.
const pyprojectAfter = sha256OfFile(path.join(updateRoot, 'pyproject.toml'))
const pyprojectChanged = pyprojectBefore && pyprojectAfter && pyprojectBefore !== pyprojectAfter
const venvPython = pyprojectChanged ? findPythonForRoot(updateRoot) : null
if (venvPython && fileExists(venvPython)) {
emitUpdateProgress({ stage: 'pydeps', message: 'Updating Python dependencies…', percent: 75 })
await runProcess(venvPython, ['-m', 'pip', 'install', '-e', updateRoot, '--disable-pip-version-check'])
}
emitUpdateProgress({ stage: 'restart', message: 'Update complete. Restarting…', percent: 100 })
// Give the OS a beat to register the new process, then quit. The updater
// rebuilds and relaunches us when it's done.
setTimeout(() => {
app.relaunch()
app.quit()
}, 1500)
}, 600)
return { ok: true, branch }
return { ok: true, handedOff: true, updater }
} finally {
updateInFlight = false
}
@ -1166,18 +1101,6 @@ function readJson(filePath) {
}
}
// Used by applyUpdates() to detect pyproject.toml drift after `git pull` so
// we know whether to re-run `pip install -e .` against the venv. Returns
// null on read failure.
function sha256OfFile(filePath) {
try {
const buf = fs.readFileSync(filePath)
return crypto.createHash('sha256').update(buf).digest('hex')
} catch {
return null
}
}
// Bootstrap-complete marker helpers. The marker is written ONCE by the
// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed
// AND the user has finished initial configuration. On every subsequent boot

View file

@ -63,7 +63,7 @@ export function UpdatesOverlay() {
}
const handleInstall = () => {
void applyUpdates({ dirtyStrategy: status?.dirty ? 'stash' : 'abort' })
void applyUpdates()
}
return (
@ -248,7 +248,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
Hermes will reopen automatically when this is done.
The Hermes updater will take over in its own window and reopen Hermes when it&rsquo;s done.
</DialogDescription>
</div>
@ -262,7 +262,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
/>
</div>
<p className="text-center text-xs text-muted-foreground">Please keep this window open.</p>
<p className="text-center text-xs text-muted-foreground">Hermes will close to apply the update.</p>
</div>
)
}

View file

@ -6770,6 +6770,26 @@ def cmd_gui(args):
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
# --build-only: produce the artifact but do NOT launch. The installer's
# --update flow drives the rebuild headlessly and then launches the desktop
# itself (detached, after the old exe has exited), so the launch must NOT
# happen here — it would block the installer and, on Windows, the old exe
# is still being replaced. Verify the expected artifact exists so a silent
# "built nothing" can't slip past, then return success.
if getattr(args, "build_only", False):
if source_mode:
if not _desktop_dist_exists(desktop_dir):
print(f"✗ --build-only --source produced no dist at: {desktop_dir / 'dist'}")
sys.exit(1)
print(f"✓ Desktop source build ready at {desktop_dir / 'dist'} (not launching; --build-only)")
elif packaged_executable is None:
print(f"✗ --build-only produced no launchable app at: {desktop_dir / 'release'}")
print(" Expected an unpacked Electron app for the current OS.")
sys.exit(1)
else:
print(f"✓ Desktop packaged app ready: {packaged_executable} (not launching; --build-only)")
return
if source_mode:
print("→ Launching Hermes Desktop from source build...")
launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False)
@ -14165,6 +14185,11 @@ Examples:
action="store_true",
help="Launch via `electron .` against apps/desktop/dist instead of the packaged app",
)
gui_parser.add_argument(
"--build-only",
action="store_true",
help="Build the desktop app but do not launch it (used by the installer's --update flow)",
)
gui_parser.add_argument(
"--fake-boot",
action="store_true",

View file

@ -2073,9 +2073,11 @@ function Install-Desktop {
"$desktopDir\release\win-arm64-unpacked\Hermes.exe"
)
$found = $false
$desktopExe = $null
foreach ($cand in $exeCandidates) {
if (Test-Path $cand) {
Write-Success "Desktop ready: $cand"
$desktopExe = $cand
$found = $true
break
}
@ -2083,6 +2085,49 @@ function Install-Desktop {
if (-not $found) {
throw "Desktop build completed but no Hermes.exe was found under $desktopDir\release\*-unpacked\"
}
# 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
# Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
# command rebuilds (npm install + electron-builder) on every launch,
# which would cost minutes each time. The packed exe is the consumer —
# launching it directly is instant, and updates flow through the
# installer's --update path (which rebuilds once, then relaunches).
New-DesktopShortcuts -TargetExe $desktopExe
}
function New-DesktopShortcuts {
param([Parameter(Mandatory = $true)][string]$TargetExe)
# Best-effort: a shortcut failure must never fail an otherwise-good install.
try {
$shell = New-Object -ComObject WScript.Shell
$workDir = Split-Path -Parent $TargetExe
$targets = @(
(Join-Path ([Environment]::GetFolderPath('Programs')) 'Hermes.lnk'),
(Join-Path ([Environment]::GetFolderPath('Desktop')) 'Hermes.lnk')
)
foreach ($lnkPath in $targets) {
try {
$parent = Split-Path -Parent $lnkPath
if (-not (Test-Path $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
$sc = $shell.CreateShortcut($lnkPath)
$sc.TargetPath = $TargetExe
$sc.WorkingDirectory = $workDir
$sc.IconLocation = "$TargetExe,0"
$sc.Description = 'Hermes Agent'
$sc.Save()
Write-Success "Shortcut created: $lnkPath"
} catch {
Write-Warn "Could not create shortcut $lnkPath : $($_.Exception.Message)"
}
}
} catch {
Write-Warn "Skipping shortcut creation: $($_.Exception.Message)"
}
}
function Install-PlatformSdks {