From 6381e704489428aab4b1615472ec90faf7bd86a9 Mon Sep 17 00:00:00 2001 From: emozilla Date: Thu, 28 May 2026 23:48:21 -0400 Subject: [PATCH] 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). --- .../src-tauri/src/bootstrap.rs | 12 + apps/bootstrap-installer/src-tauri/src/lib.rs | 76 ++- .../src-tauri/src/paths.rs | 49 ++ .../src-tauri/src/update.rs | 435 ++++++++++++++++++ apps/bootstrap-installer/src/store.ts | 28 +- apps/desktop/electron/main.cjs | 161 ++----- apps/desktop/src/app/updates-overlay.tsx | 6 +- hermes_cli/main.py | 25 + scripts/install.ps1 | 45 ++ 9 files changed, 709 insertions(+), 128 deletions(-) create mode 100644 apps/bootstrap-installer/src-tauri/src/update.rs diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 94fe43304a0a..529b3b447187 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -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 { diff --git a/apps/bootstrap-installer/src-tauri/src/lib.rs b/apps/bootstrap-installer/src-tauri/src/lib.rs index f26ebe47556b..a710ce9b5018 100644 --- a/apps/bootstrap-installer/src-tauri/src/lib.rs +++ b/apps/bootstrap-installer/src-tauri/src/lib.rs @@ -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(args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + 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>, + /// 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>) -> 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::::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 + ); + } +} diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 54e9c138df3e..3cc22574200d 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -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') diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs new file mode 100644 index 000000000000..599b7d395f1e --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -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 ` 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 { + 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, +} + +/// 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 { + 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, + error: Option, +) { + 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"))); + } +} diff --git a/apps/bootstrap-installer/src/store.ts b/apps/bootstrap-installer/src/store.ts index b7693a394cc3..63333e23dd55 100644 --- a/apps/bootstrap-installer/src/store.ts +++ b/apps/bootstrap-installer/src/store.ts @@ -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('welcome') +export const $mode = atom('install') export const $bootstrap = atom(INITIAL) export const $logPath = atom(null) export const $hermesHome = atom(null) @@ -128,12 +134,14 @@ export async function initialize(): Promise { // Pull static info on mount for the diagnostics footer. try { - const [logPath, hermesHome] = await Promise.all([ + const [logPath, hermesHome, mode] = await Promise.all([ invoke('get_log_path'), - invoke('get_hermes_home') + invoke('get_hermes_home'), + invoke('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 { 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 { }) } +export async function startUpdate(): Promise { + // 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 { await invoke('cancel_bootstrap') } diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 677a02726764..307454f0a3c6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 398b4013b7c0..e50303947329 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -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 }) { {label} - Hermes will reopen automatically when this is done. + The Hermes updater will take over in its own window and reopen Hermes when it’s done. @@ -262,7 +262,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) { /> -

Please keep this window open.

+

Hermes will close to apply the update.

) } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 88b9e91566cd..3cbf30f48fe5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fa39c475bd90..db1aa454a4be 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 {