mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
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:
parent
a4cfc8b740
commit
6381e70448
9 changed files with 709 additions and 128 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
435
apps/bootstrap-installer/src-tauri/src/update.rs
Normal file
435
apps/bootstrap-installer/src-tauri/src/update.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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’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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue