forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,906 @@
|
||||
//! Bootstrap orchestration.
|
||||
//!
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
|
||||
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
||||
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
||||
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. `start_bootstrap` (Tauri command) → spawns the worker task.
|
||||
//! 2. Worker resolves install script (dev/cache/download).
|
||||
//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event.
|
||||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::events::{BootstrapEvent, LogStream, Manifest, StageState};
|
||||
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
|
||||
use crate::powershell::{self, StreamSink};
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frontend → Rust: kick off the install.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StartBootstrapArgs {
|
||||
/// Optional override for the commit pin. Defaults to the build-time
|
||||
/// pin baked in via `BUILD_PIN_COMMIT`.
|
||||
pub commit: Option<String>,
|
||||
/// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`.
|
||||
pub branch: Option<String>,
|
||||
/// Include Stage-Desktop (build apps/desktop) in the manifest. The
|
||||
/// signed bootstrap installer passes true; the deprecated Electron-side
|
||||
/// bootstrap-runner passes false to avoid building-while-running.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_desktop: bool,
|
||||
/// Optional override for HERMES_HOME. Tests use this; production
|
||||
/// almost always falls back to the OS default.
|
||||
pub hermes_home: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BootstrapStatus {
|
||||
pub running: bool,
|
||||
pub completed: bool,
|
||||
pub install_root: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Handle stored in AppState while a bootstrap run is in flight. Carries
|
||||
/// the cancellation channel and the most recent terminal status so the
|
||||
/// frontend can re-query after a window refresh.
|
||||
pub struct BootstrapHandle {
|
||||
pub cancel_tx: mpsc::Sender<()>,
|
||||
pub started_at: Instant,
|
||||
pub status: BootstrapStatus,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_bootstrap(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
args: StartBootstrapArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
if h.status.running {
|
||||
return Err("Bootstrap is already running".into());
|
||||
}
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||
let handle = BootstrapHandle {
|
||||
cancel_tx,
|
||||
started_at: Instant::now(),
|
||||
status: BootstrapStatus {
|
||||
running: true,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
};
|
||||
*guard = Some(handle);
|
||||
drop(guard);
|
||||
|
||||
let app_for_task = app.clone();
|
||||
let state_for_task = state.inner().clone();
|
||||
let args_for_task = args;
|
||||
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await;
|
||||
|
||||
// Reflect terminal state into AppState so get_bootstrap_status()
|
||||
// can serve it after the task exits.
|
||||
let mut guard = state_for_task.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_mut() {
|
||||
h.status.running = false;
|
||||
match &result {
|
||||
Ok(install_root) => {
|
||||
h.status.completed = true;
|
||||
h.status.install_root = Some(install_root.clone());
|
||||
h.status.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
h.status.completed = false;
|
||||
h.status.last_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_bootstrap(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
let _ = h.cancel_tx.try_send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bootstrap_status(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
) -> Result<BootstrapStatus, String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
Ok(match guard.as_ref() {
|
||||
Some(h) => BootstrapStatus {
|
||||
running: h.status.running,
|
||||
completed: h.status.completed,
|
||||
install_root: h.status.install_root.clone(),
|
||||
last_error: h.status.last_error.clone(),
|
||||
},
|
||||
None => BootstrapStatus {
|
||||
running: false,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the locally-built Hermes desktop binary, then close the installer
|
||||
/// window. Caller resolves the binary path from `install_root`.
|
||||
///
|
||||
/// Returns Err with a human-readable message if the binary doesn't exist
|
||||
/// (e.g. when Stage-Desktop was skipped) so the frontend can present
|
||||
/// actionable failure UI rather than silently doing nothing.
|
||||
#[tauri::command]
|
||||
pub async fn launch_hermes_desktop(
|
||||
app: AppHandle,
|
||||
install_root: String,
|
||||
) -> Result<(), String> {
|
||||
let install_root = PathBuf::from(install_root);
|
||||
let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| {
|
||||
format!(
|
||||
"Couldn't find a built Hermes desktop at {}. The desktop build step \
|
||||
may have been skipped or failed. Run `hermes desktop` from a \
|
||||
terminal to build and launch it.",
|
||||
install_root.join("apps").join("desktop").join("release").display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(?exe_path, "launching Hermes desktop");
|
||||
|
||||
// Detach from us — the installer is about to exit. On macOS launch the
|
||||
// bundle through LaunchServices instead of exec'ing Contents/MacOS/Hermes
|
||||
// directly; this matches user double-click/open behavior and avoids cwd /
|
||||
// quarantine oddities after a self-update rebuild.
|
||||
let mut cmd = desktop_launch_command(&exe_path, &install_root);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"failed to launch {}: {e}",
|
||||
exe_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Give Windows ~150ms to actually start the new process before we exit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Exit the installer cleanly. Tauri's process plugin gives us the
|
||||
// right hook regardless of platform.
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
("win-unpacked", "Hermes.exe"),
|
||||
("win-arm64-unpacked", "Hermes.exe"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[
|
||||
("mac/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
]
|
||||
} else {
|
||||
&[("linux-unpacked", "hermes")]
|
||||
};
|
||||
for (subdir, exe) in candidates {
|
||||
let p = release_dir.join(subdir).join(exe);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_hermes_desktop_app(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app
|
||||
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
|
||||
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
|
||||
return Some(app);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
return Some(exe);
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a prior install completed (bootstrap-complete marker present) AND a
|
||||
/// launchable desktop app exists on disk. Used by the installer's launcher fast
|
||||
/// path so a bare re-open just opens Hermes instead of re-running setup.
|
||||
pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
||||
install_root.join(".hermes-bootstrap-complete").exists()
|
||||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
|
||||
})?;
|
||||
let mut cmd = desktop_launch_command_std(&exe, install_root);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the
|
||||
// installer exits, mirroring launch_hermes_desktop. Kept correct here
|
||||
// even though the only caller is macOS-gated today, so future reuse on
|
||||
// Windows doesn't reintroduce the relaunch race.
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn open_macos_app_detached(app_bundle: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/open");
|
||||
cmd.arg(app_bundle);
|
||||
cmd.current_dir(crate::paths::hermes_home());
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn app_bundle_for_exe(exe: &std::path::Path) -> Option<PathBuf> {
|
||||
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
|
||||
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
|
||||
Some(app)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_launch_command(
|
||||
exe_path: &std::path::Path,
|
||||
install_root: &std::path::Path,
|
||||
) -> tokio::process::Command {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
|
||||
let mut cmd = tokio::process::Command::new("/usr/bin/open");
|
||||
cmd.arg(app_bundle);
|
||||
cmd.current_dir(crate::paths::hermes_home());
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
|
||||
cmd
|
||||
}
|
||||
|
||||
fn desktop_launch_command_std(
|
||||
exe_path: &std::path::Path,
|
||||
install_root: &std::path::Path,
|
||||
) -> std::process::Command {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/open");
|
||||
cmd.arg(app_bundle);
|
||||
cmd.current_dir(crate::paths::hermes_home());
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = std::process::Command::new(exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
|
||||
cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_bootstrap(
|
||||
app: AppHandle,
|
||||
args: StartBootstrapArgs,
|
||||
cancel_rx_holder: Arc<Mutex<Option<mpsc::Receiver<()>>>>,
|
||||
) -> Result<String> {
|
||||
let kind = ScriptKind::for_current_os();
|
||||
|
||||
let pin = Pin {
|
||||
commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")),
|
||||
branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
?pin,
|
||||
kind = ?kind,
|
||||
include_desktop = args.include_desktop,
|
||||
"bootstrap starting"
|
||||
);
|
||||
|
||||
let app_for_log = app.clone();
|
||||
let emit_log = move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_log,
|
||||
BootstrapEvent::Log {
|
||||
stage: None,
|
||||
line: line.to_string(),
|
||||
stream: LogStream::Stdout,
|
||||
},
|
||||
);
|
||||
// Bump to info-level so the line shows in bootstrap-installer.log
|
||||
// under the default INFO filter. Previously this was debug! which
|
||||
// got dropped on the floor, leaving us blind whenever install.ps1
|
||||
// failed — the log only had the "bootstrap starting" banner.
|
||||
tracing::info!(target: "bootstrap.log", "{line}");
|
||||
};
|
||||
|
||||
// 1. Resolve install.ps1
|
||||
let script = install_script::resolve(kind, &pin, &emit_log)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("resolve install script failed: {e:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let source_note = match &script.source {
|
||||
ScriptSource::DevCheckout => "dev checkout",
|
||||
ScriptSource::Bundled => "bundled",
|
||||
ScriptSource::Cached => "cached",
|
||||
ScriptSource::Downloaded => "downloaded",
|
||||
};
|
||||
emit_log(&format!(
|
||||
"[bootstrap] script {} via {}",
|
||||
script.path.display(),
|
||||
source_note
|
||||
));
|
||||
|
||||
// 2. Fetch manifest
|
||||
//
|
||||
// -IncludeDesktop MUST be passed to the manifest call too — install.ps1
|
||||
// gates the desktop stage inclusion on this flag, so without it here
|
||||
// the manifest comes back missing the desktop stage and we never run
|
||||
// it. The per-stage call below also passes -IncludeDesktop to keep
|
||||
// the contracts identical.
|
||||
let manifest_args = build_pin_args(&script);
|
||||
let mut manifest_args_full = vec!["-Manifest".to_string()];
|
||||
manifest_args_full.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
manifest_args_full.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
let manifest_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&manifest_args_full,
|
||||
args.hermes_home.as_deref(),
|
||||
None,
|
||||
Some("__manifest__".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if manifest_result.exit_code != Some(0) {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest failed: exit {:?}\n{}",
|
||||
manifest_result.exit_code,
|
||||
manifest_result.stderr.trim()
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest produced no parseable JSON payload\n{}",
|
||||
truncate(&manifest_result.stdout, 4000)
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(err)
|
||||
})?;
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: manifest.stages.clone(),
|
||||
protocol_version: manifest.protocol_version,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Iterate stages.
|
||||
for stage in &manifest.stages {
|
||||
// Skip Stage-Desktop unless explicitly requested. install.ps1 may
|
||||
// or may not include it in the manifest depending on the flag we
|
||||
// pass, but if it slipped in, gate client-side too.
|
||||
if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(0),
|
||||
result: None,
|
||||
error: Some("skipped by include_desktop=false".into()),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if cancellation_signalled(&cancel_rx_holder).await {
|
||||
let err = "bootstrap cancelled by user".to_string();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Running,
|
||||
duration_ms: None,
|
||||
result: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut stage_args = vec![
|
||||
"-Stage".to_string(),
|
||||
stage.name.clone(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Json".to_string(),
|
||||
];
|
||||
stage_args.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
stage_args.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
// Each stage gets its own cancel receiver because tokio::select!
|
||||
// in run_script consumes it. Take/return through the Arc<Mutex>.
|
||||
let local_cancel_rx = cancel_rx_holder.lock().await.take();
|
||||
|
||||
let stage_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&stage_args,
|
||||
args.hermes_home.as_deref(),
|
||||
local_cancel_rx,
|
||||
Some(stage.name.clone()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if stage_result.killed {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some("cancelled by user".into()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: "cancelled by user".into(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!("cancelled by user"));
|
||||
}
|
||||
|
||||
let result_frame = powershell::parse_stage_result(&stage_result.stdout);
|
||||
|
||||
match result_frame {
|
||||
None => {
|
||||
let err = format!(
|
||||
"install.ps1 -Stage {} produced no JSON result frame (exit={:?})",
|
||||
stage.name, stage_result.exit_code
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
Some(frame) if frame.ok && frame.skipped => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) if frame.ok => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Succeeded,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) => {
|
||||
let err = frame
|
||||
.reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code));
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve install_root. install.ps1 doesn't (yet) report this back
|
||||
// explicitly; we infer it from $HermesHome which Stage-Repository clones
|
||||
// the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants.
|
||||
let hermes_home = args
|
||||
.hermes_home
|
||||
.clone()
|
||||
.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 {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(install_root.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
async fn cancellation_signalled(holder: &Arc<Mutex<Option<mpsc::Receiver<()>>>>) -> bool {
|
||||
let mut guard = holder.lock().await;
|
||||
if let Some(rx) = guard.as_mut() {
|
||||
rx.try_recv().is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_install_script(
|
||||
app: &AppHandle,
|
||||
script_path: &std::path::Path,
|
||||
args: &[String],
|
||||
hermes_home_override: Option<&str>,
|
||||
cancel_rx: Option<mpsc::Receiver<()>>,
|
||||
stage_name: Option<String>,
|
||||
) -> Result<powershell::ScriptResult> {
|
||||
let app_for_stdout = app.clone();
|
||||
let stage_for_stdout = stage_name.clone();
|
||||
let app_for_stderr = app.clone();
|
||||
let stage_for_stderr = stage_name.clone();
|
||||
let stage_for_stdout_log = stage_name.clone();
|
||||
let stage_for_stderr_log = stage_name.clone();
|
||||
|
||||
let sink = StreamSink {
|
||||
on_stdout_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stdout,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stdout.clone(),
|
||||
line: line.to_string(),
|
||||
stream: LogStream::Stdout,
|
||||
},
|
||||
);
|
||||
// Tee to the rolling installer log so we have a persistent
|
||||
// record of every install.ps1 line. Without this, the only
|
||||
// log evidence of a failure was the Tauri event stream —
|
||||
// which gets discarded the moment the failure route mounts.
|
||||
match &stage_for_stdout_log {
|
||||
Some(name) => {
|
||||
tracing::info!(target: "bootstrap.log", stage = %name, "{line}")
|
||||
}
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
}
|
||||
}),
|
||||
on_stderr_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stderr,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stderr.clone(),
|
||||
line: line.to_string(),
|
||||
stream: LogStream::Stderr,
|
||||
},
|
||||
);
|
||||
// stderr-level lines get warn! so they're visually distinct
|
||||
// when scrolling through the log later.
|
||||
match &stage_for_stderr_log {
|
||||
Some(name) => {
|
||||
tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}")
|
||||
}
|
||||
None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"),
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(?e, "install script invocation failed");
|
||||
anyhow!("install script invocation failed: {e:#}")
|
||||
})
|
||||
}
|
||||
|
||||
fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = &script.commit {
|
||||
out.push("-Commit".to_string());
|
||||
out.push(c.clone());
|
||||
}
|
||||
if let Some(b) = &script.branch {
|
||||
out.push("-Branch".to_string());
|
||||
out.push(b.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event: BootstrapEvent) {
|
||||
// Tee important state transitions to the rolling installer log so
|
||||
// bootstrap-installer.log isn't just "starting" + final summary.
|
||||
// Log lines (the noisy stuff) handle their own tracing in
|
||||
// run_install_script's sink; here we cover the lifecycle frames.
|
||||
match &event {
|
||||
BootstrapEvent::Manifest { stages, .. } => {
|
||||
tracing::info!(
|
||||
stage_count = stages.len(),
|
||||
names = ?stages.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
|
||||
"manifest received"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Stage {
|
||||
name,
|
||||
state,
|
||||
duration_ms,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
tracing::info!(
|
||||
stage = %name,
|
||||
?state,
|
||||
duration_ms = ?duration_ms,
|
||||
error = ?error,
|
||||
"stage transition"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Complete { install_root, .. } => {
|
||||
tracing::info!(install_root = %install_root, "bootstrap complete");
|
||||
}
|
||||
BootstrapEvent::Failed { stage, error } => {
|
||||
tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED");
|
||||
}
|
||||
BootstrapEvent::Log { .. } => {
|
||||
// Log lines are teed via the sink callbacks in
|
||||
// run_install_script — don't double-emit here.
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit bootstrap event");
|
||||
}
|
||||
}
|
||||
|
||||
fn option_env_string(key: &str) -> Option<String> {
|
||||
// option_env! only accepts literals, so we hardcode the known keys.
|
||||
let val = match key {
|
||||
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
||||
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
fn unique_tmp_dir(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"hermes-bootstrap-test-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
// Build a fake built-desktop release tree at the platform's expected path
|
||||
// and return (install_root, expected_app_bundle_or_exe).
|
||||
fn make_release_tree(install_root: &Path) -> PathBuf {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "macos") {
|
||||
let macos_dir = release
|
||||
.join("mac-arm64")
|
||||
.join("Hermes.app")
|
||||
.join("Contents")
|
||||
.join("MacOS");
|
||||
std::fs::create_dir_all(&macos_dir).unwrap();
|
||||
std::fs::write(macos_dir.join("Hermes"), b"#!/bin/sh\n").unwrap();
|
||||
macos_dir.parent().unwrap().parent().unwrap().to_path_buf() // .../Hermes.app
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let dir = release.join("win-unpacked");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let exe = dir.join("Hermes.exe");
|
||||
std::fs::write(&exe, b"stub").unwrap();
|
||||
exe
|
||||
} else {
|
||||
let dir = release.join("linux-unpacked");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let exe = dir.join("hermes");
|
||||
std::fs::write(&exe, b"stub").unwrap();
|
||||
exe
|
||||
}
|
||||
}
|
||||
|
||||
// The relaunch / install target is derived from the rebuilt desktop app.
|
||||
// On macOS this MUST resolve to the .app bundle (what `open` relaunches and
|
||||
// what the updater ditto's over /Applications/Hermes.app). A regression in
|
||||
// this derivation breaks the post-update auto-relaunch, so guard it.
|
||||
#[test]
|
||||
fn resolve_hermes_desktop_app_finds_built_bundle() {
|
||||
let root = unique_tmp_dir("app-ok");
|
||||
let expected = make_release_tree(&root);
|
||||
|
||||
let resolved = resolve_hermes_desktop_app(&root)
|
||||
.expect("should resolve the freshly-built desktop app");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert_eq!(resolved, expected, "must resolve to the .app bundle");
|
||||
assert_eq!(
|
||||
resolved.extension().and_then(|e| e.to_str()),
|
||||
Some("app"),
|
||||
"relaunch target must be a .app bundle on macOS"
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
assert_eq!(resolved, expected);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_hermes_desktop_app_is_none_without_a_build() {
|
||||
let root = unique_tmp_dir("app-none");
|
||||
// No release tree created.
|
||||
assert!(
|
||||
resolve_hermes_desktop_app(&root).is_none(),
|
||||
"no resolved app when nothing has been built"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
//! The Tauri event channel name is `"bootstrap"` for all of these — the
|
||||
//! `type` discriminator on each payload is how the frontend routes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stage definition as reported by `install.ps1 -Manifest`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
/// `needs_user_input=true` stages run with -NonInteractive and emit
|
||||
/// skipped=true; the post-install wizard takes over for those.
|
||||
#[serde(rename = "needs_user_input", alias = "needsUserInput")]
|
||||
pub needs_user_input: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocol_version", alias = "protocolVersion", default)]
|
||||
pub protocol_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageResultPayload {
|
||||
pub stage: String,
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub skipped: bool,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// install.ps1 may attach stage-specific structured data here.
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run-state for a single stage as we transition through it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StageState {
|
||||
Running,
|
||||
Succeeded,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Which pipe a raw log line came from. Reported as structured metadata so
|
||||
/// the UI can style stderr subtly rather than mislabeling it as an error:
|
||||
/// uv/pip/git/npm write normal progress to stderr by design.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// The single event channel `bootstrap` emits these. `type` discriminates.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum BootstrapEvent {
|
||||
/// Sent once at the start with the full stage list.
|
||||
Manifest {
|
||||
stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocolVersion")]
|
||||
protocol_version: Option<u32>,
|
||||
},
|
||||
/// Stage state transition. `result` populated only on terminal states.
|
||||
Stage {
|
||||
name: String,
|
||||
state: StageState,
|
||||
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<StageResultPayload>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper). `stream`
|
||||
/// tells the UI which pipe it came from so stderr can be styled subtly
|
||||
/// instead of being mislabeled as an error.
|
||||
Log {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
line: String,
|
||||
stream: LogStream,
|
||||
},
|
||||
/// Sent once when all stages complete successfully.
|
||||
Complete {
|
||||
#[serde(rename = "installRoot")]
|
||||
install_root: String,
|
||||
marker: Option<serde_json::Value>,
|
||||
},
|
||||
/// Sent once if the run aborts.
|
||||
Failed {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BootstrapEvent {
|
||||
/// Tauri event name. Single channel for all bootstrap events; the
|
||||
/// `type` tag tells the renderer how to interpret the payload.
|
||||
pub const CHANNEL: &'static str = "bootstrap";
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Resolves and downloads `scripts/install.ps1` (and `install.sh`).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT
|
||||
//! env var. Lets devs iterate without re-publishing the script.
|
||||
//! 2. Bundled fallback: if the installer was bundled with a script (e.g.
|
||||
//! tauri's `resource` mechanism), serve from there. Not used today.
|
||||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Identity of the install.ps1 we'll execute. Used by both the manifest
|
||||
/// fetch and the per-stage runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedScript {
|
||||
pub path: PathBuf,
|
||||
pub source: ScriptSource,
|
||||
/// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is
|
||||
/// what makes the repo stage clone the exact tested SHA.
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScriptSource {
|
||||
DevCheckout,
|
||||
Bundled,
|
||||
Cached,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// What flavor of script (Windows .ps1 vs Unix .sh).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScriptKind {
|
||||
Ps1,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl ScriptKind {
|
||||
pub fn for_current_os() -> Self {
|
||||
if cfg!(target_os = "windows") {
|
||||
Self::Ps1
|
||||
} else {
|
||||
Self::Sh
|
||||
}
|
||||
}
|
||||
|
||||
fn filename(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ps1 => "install.ps1",
|
||||
Self::Sh => "install.sh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Resolves the install script to use for this run.
|
||||
///
|
||||
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
|
||||
/// constant (compiled into the installer) or a runtime override.
|
||||
pub async fn resolve(
|
||||
kind: ScriptKind,
|
||||
pin: &Pin,
|
||||
emit_log: &impl Fn(&str),
|
||||
) -> Result<ResolvedScript> {
|
||||
// 1. Dev shortcut.
|
||||
if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") {
|
||||
let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename());
|
||||
if candidate.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] dev mode — using local {} at {}",
|
||||
kind.filename(),
|
||||
candidate.display()
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: candidate,
|
||||
source: ScriptSource::DevCheckout,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Not implemented) bundled fallback.
|
||||
|
||||
// 3. Network. Pin must be a real commit or a branch ref.
|
||||
let commit_or_ref = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => c.clone(),
|
||||
(_, Some(b)) if !b.trim().is_empty() => b.clone(),
|
||||
(Some(other), _) => {
|
||||
return Err(anyhow!(
|
||||
"install script pin commit `{other}` is not a valid git SHA"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"no install-script pin supplied — installer cannot resolve a script source"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cached = cached_path(kind, &commit_or_ref);
|
||||
if cached.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} from GitHub",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
download(kind, &commit_or_ref, &cached).await?;
|
||||
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Pin {
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf {
|
||||
let safe = sanitize_ref(commit_or_ref);
|
||||
let filename = match kind {
|
||||
ScriptKind::Ps1 => format!("install-{safe}.ps1"),
|
||||
ScriptKind::Sh => format!("install-{safe}.sh"),
|
||||
};
|
||||
paths::bootstrap_cache_dir().join(filename)
|
||||
}
|
||||
|
||||
/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can
|
||||
/// contain `/`, dots, etc.; we want a flat filename.
|
||||
fn sanitize_ref(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_ref(s: &str) -> &str {
|
||||
if is_valid_commit(s) && s.len() >= 12 {
|
||||
&s[..12]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
|
||||
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
|
||||
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
|
||||
commit_or_ref,
|
||||
kind.filename()
|
||||
);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("creating bootstrap-cache parent dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let tmp_path = dest_path.with_extension({
|
||||
let ext = dest_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("tmp");
|
||||
format!("{ext}.tmp")
|
||||
});
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", "hermes-setup/0.0.1")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to download {}: HTTP {} from {}",
|
||||
kind.filename(),
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("creating temp file {}", tmp_path.display()))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing temp file {}", tmp_path.display()))?;
|
||||
file.flush().await.context("flushing temp file")?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, dest_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_valid_commit_accepts_short_and_full_shas() {
|
||||
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
|
||||
assert!(is_valid_commit("02d2698"));
|
||||
assert!(!is_valid_commit("02d269"));
|
||||
assert!(!is_valid_commit("not-a-sha"));
|
||||
assert!(!is_valid_commit(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ref_replaces_slashes() {
|
||||
assert_eq!(sanitize_ref("bb/gui"), "bb_gui");
|
||||
assert_eq!(sanitize_ref("main"), "main");
|
||||
assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Hermes Setup — Tauri entrypoint.
|
||||
//!
|
||||
//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
|
||||
//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
|
||||
//! commands registered at the bottom of `run()`.
|
||||
//!
|
||||
//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
|
||||
//! here — a crate-level attribute on a lib doesn't propagate to the linker
|
||||
//! flags of the executable that consumes it.
|
||||
|
||||
mod bootstrap;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the args request a forced installer UI (repair/reinstall)
|
||||
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
|
||||
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
|
||||
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
|
||||
/// these flags never flip Install<->Update.
|
||||
pub fn force_setup_from_args<I, S>(args: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
/// of these per window. `Arc<Mutex<...>>` lets command handlers grab it
|
||||
/// 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 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
|
||||
// failures leave a trail for support. Console output also goes here in
|
||||
// debug builds.
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
|
||||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer 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::new(mode)))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
// Hermes is already installed should NOT show the installer or
|
||||
// rebuild — it should just open the app, so the /Applications
|
||||
// "Hermes" doubles as a normal launcher (first run installs, every
|
||||
// later run launches instantly). The window is kept hidden until
|
||||
// here via `"visible": false` so this path never flashes a window.
|
||||
//
|
||||
// Gated to macOS deliberately: on Windows/Linux the installer keeps
|
||||
// its existing behavior (Windows users relaunch via the Start
|
||||
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
|
||||
// reliable detached relaunch there needs the DETACHED_PROCESS +
|
||||
// startup-grace handling used by launch_hermes_desktop — out of
|
||||
// scope here). So this is a pure no-op on non-macOS.
|
||||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
Ok(()) => {
|
||||
// Brief grace so the spawned app is registered
|
||||
// before we exit (mirrors launch_hermes_desktop).
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tracing::info!(
|
||||
"hermes already installed — relaunched desktop; exiting installer"
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"relaunch of installed desktop failed; showing installer UI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First run / repair install, or Update mode: reveal the UI.
|
||||
match app.get_webview_window("main") {
|
||||
Some(win) => {
|
||||
if let Err(err) = win.show() {
|
||||
tracing::error!(?err, "failed to show main installer window");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("main installer window not found; installer UI will not appear");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.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
|
||||
paths::get_log_path,
|
||||
paths::get_hermes_home,
|
||||
paths::open_log_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermes Setup");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{force_setup_from_args, 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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_and_repair_flags_force_setup() {
|
||||
assert!(force_setup_from_args(["--reinstall"]));
|
||||
assert!(force_setup_from_args(["--repair"]));
|
||||
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_or_unrelated_args_do_not_force_setup() {
|
||||
assert!(!force_setup_from_args(Vec::<String>::new()));
|
||||
assert!(!force_setup_from_args(["--foo", "bar"]));
|
||||
// --update must not be mistaken for a force-setup flag.
|
||||
assert!(!force_setup_from_args(["--update"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_setup_flags_do_not_affect_mode_selection() {
|
||||
// The repair flags must never flip Install<->Update.
|
||||
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--update", "--reinstall"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can
|
||||
// be unit-tested as a library; this file just calls into it.
|
||||
//
|
||||
// The windows_subsystem attribute MUST live here on the binary crate
|
||||
// (not lib.rs) — placing it on the lib was the bug that left a stray
|
||||
// cmd window behind Hermes-Setup.exe on release builds.
|
||||
//
|
||||
// `windows_subsystem = "windows"` strips the console allocation that
|
||||
// the default `windows_subsystem = "console"` would do, so double-clicking
|
||||
// the .exe gives you ONLY the Tauri window.
|
||||
//
|
||||
// debug_assertions guard: dev builds keep the console so tracing output
|
||||
// is visible during `cargo tauri dev`.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_bootstrap_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Filesystem paths + logging setup.
|
||||
//!
|
||||
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
|
||||
//! Windows: %LOCALAPPDATA%\hermes
|
||||
//! macOS: ~/.hermes
|
||||
//! Linux: ~/.hermes (override via $HERMES_HOME)
|
||||
//!
|
||||
//! NOTE (macOS): Python's get_hermes_home(), scripts/install.sh, and the
|
||||
//! Electron desktop's resolveHermesHome() ALL use ~/.hermes on macOS — there
|
||||
//! is no ~/Library/Application Support branch anywhere else. An earlier
|
||||
//! version of this file used Application Support, which drifted from every
|
||||
//! other component: the installer wrote the install to one dir and the
|
||||
//! desktop looked for it in another, so first launch never found the backend.
|
||||
//!
|
||||
//! IMPORTANT: this must match exactly. Drift here means install.ps1
|
||||
//! writes to one place and the installer reads from another, breaking
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
pub fn hermes_home() -> PathBuf {
|
||||
if let Ok(override_path) = std::env::var("HERMES_HOME") {
|
||||
if !override_path.trim().is_empty() {
|
||||
return PathBuf::from(override_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome.
|
||||
if let Some(local_app_data) = dirs::data_local_dir() {
|
||||
return local_app_data.join("hermes");
|
||||
}
|
||||
}
|
||||
|
||||
// macOS + Linux + fallback: ~/.hermes (matches Python get_hermes_home(),
|
||||
// install.sh, and the Electron desktop's resolveHermesHome()).
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".hermes");
|
||||
}
|
||||
|
||||
// Last resort — current dir, almost certainly wrong but at least
|
||||
// doesn't panic.
|
||||
PathBuf::from(".hermes")
|
||||
}
|
||||
|
||||
pub fn log_dir() -> PathBuf {
|
||||
hermes_home().join("logs")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
log_dir().join("bootstrap-installer.log")
|
||||
}
|
||||
|
||||
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)?;
|
||||
repair_macos_installer_helper(&dest);
|
||||
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn repair_macos_installer_helper(path: &Path) {
|
||||
// The staged helper may inherit quarantine from the downloaded installer.
|
||||
// Desktop later launches this exact file for in-app updates, so make it
|
||||
// executable before the update handoff reaches LaunchServices/Gatekeeper.
|
||||
let _ = Command::new("/usr/bin/xattr")
|
||||
.args(["-cr"])
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
let verify = Command::new("/usr/bin/codesign")
|
||||
.arg("--verify")
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
if !matches!(verify, Ok(status) if status.success()) {
|
||||
let _ = Command::new("/usr/bin/codesign")
|
||||
.args(["--force", "--sign", "-"])
|
||||
.arg(path)
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// 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')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".hermes-bootstrap-complete")
|
||||
}
|
||||
|
||||
/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/.
|
||||
/// Returns a guard that flushes the appender on drop — keep it alive for
|
||||
/// the lifetime of the process.
|
||||
pub fn init_logging() -> Option<WorkerGuard> {
|
||||
let dir = log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
// No log dir → log to stderr only. Don't panic; the installer
|
||||
// should still be usable on an exotic filesystem.
|
||||
eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_path() -> String {
|
||||
log_path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_home() -> String {
|
||||
hermes_home().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let path = log_dir();
|
||||
app.opener()
|
||||
.open_path(path.to_string_lossy(), None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
//! On Unix we shell out to `bash <script>` since install.sh expects bash.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Hooks the caller installs to receive output.
|
||||
pub struct StreamSink {
|
||||
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub killed: bool,
|
||||
}
|
||||
|
||||
/// Cancellation signal — `cancel_tx.send(()).await` aborts the running script.
|
||||
pub type CancelRx = mpsc::Receiver<()>;
|
||||
|
||||
/// Spawns install.ps1 / install.sh with the given args and streams output.
|
||||
///
|
||||
/// `hermes_home_override` propagates to the child as $HERMES_HOME so the
|
||||
/// install script writes to the same directory the installer is reading from.
|
||||
pub async fn run_script(
|
||||
script_path: &Path,
|
||||
args: &[String],
|
||||
sink: StreamSink,
|
||||
hermes_home_override: Option<&str>,
|
||||
mut cancel_rx: Option<CancelRx>,
|
||||
) -> Result<ScriptResult> {
|
||||
let mut cmd = build_command(script_path, args);
|
||||
|
||||
// The installer can be launched from a .app bundle that is later replaced
|
||||
// during self-update. Pin child scripts to a stable directory so bash/zsh
|
||||
// never starts from a deleted cwd and emits getcwd/job-working-directory
|
||||
// errors at the end of an otherwise successful install.
|
||||
if let Some(cwd) = stable_script_cwd(script_path, hermes_home_override) {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
if let Some(home) = hermes_home_override {
|
||||
cmd.env("HERMES_HOME", home);
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Windows, avoid spawning a flashing cmd window when we're hosted
|
||||
// inside a GUI process. Tauri's main window is already created, so
|
||||
// the side-effect console for the child is unwanted.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child: Child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {} via {}", script_path.display(), interpreter_label()))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
let mut killed = false;
|
||||
|
||||
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = stdout_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// EOF on stdout — wait for stderr + exit.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
line = stderr_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// stderr EOF — keep draining stdout.
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = recv_cancel(&mut cancel_rx) => {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
killed = true;
|
||||
// best-effort kill; don't propagate errors
|
||||
let _ = child.start_kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining lines after the loop exited.
|
||||
while let Ok(Some(l)) = stdout_reader.next_line().await {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
while let Ok(Some(l)) = stderr_reader.next_line().await {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("waiting for install script to exit")?;
|
||||
|
||||
Ok(ScriptResult {
|
||||
stdout: combined_stdout,
|
||||
stderr: combined_stderr,
|
||||
exit_code: status.code(),
|
||||
killed,
|
||||
})
|
||||
}
|
||||
|
||||
fn stable_script_cwd<'a>(script_path: &'a Path, hermes_home_override: Option<&'a str>) -> Option<&'a Path> {
|
||||
if let Some(home) = hermes_home_override {
|
||||
let path = Path::new(home);
|
||||
if path.is_dir() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
script_path.parent().filter(|p| p.is_dir())
|
||||
}
|
||||
|
||||
async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
match rx {
|
||||
Some(r) => {
|
||||
let _ = r.recv().await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
|
||||
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
|
||||
// over `pwsh.exe` (7+, may not be present). Resolve it by absolute path —
|
||||
// see `windows_powershell_exe`.
|
||||
let mut cmd = Command::new(windows_powershell_exe());
|
||||
cmd.arg("-NoProfile");
|
||||
cmd.arg("-ExecutionPolicy").arg("Bypass");
|
||||
cmd.arg("-File").arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// install.sh expects bash. /bin/bash is fine on macOS (Apple still
|
||||
// ships an old 3.2 bash; install.sh is written to that baseline).
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Canonical PowerShell 5.1 location under a Windows root (`%SystemRoot%`).
|
||||
/// Kept separate (and test-visible) so the path layout is unit-tested on any
|
||||
/// host, not just Windows.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn powershell_under_root(root: &Path) -> std::path::PathBuf {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
}
|
||||
|
||||
/// Resolves the PowerShell interpreter to spawn.
|
||||
///
|
||||
/// `Command::new("powershell.exe")` trusts PATH to contain
|
||||
/// `%SystemRoot%\System32\WindowsPowerShell\v1.0`. On machines whose PATH was
|
||||
/// trimmed or truncated (Windows silently drops entries once the variable grows
|
||||
/// past its length limit), that lookup fails and the spawn dies with
|
||||
/// "program not found" before install.ps1 ever runs — the installer then stalls
|
||||
/// at "0 of 0 steps". Resolve by absolute path first, then fall back to PATH
|
||||
/// (powershell 5.1, then pwsh 7), then a bare name as a last resort.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_powershell_exe() -> std::path::PathBuf {
|
||||
for var in ["SystemRoot", "windir"] {
|
||||
if let Ok(root) = std::env::var(var) {
|
||||
let candidate = powershell_under_root(Path::new(&root));
|
||||
if candidate.is_file() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for exe in ["powershell.exe", "pwsh.exe"] {
|
||||
if let Ok(found) = which::which(exe) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
std::path::PathBuf::from("powershell.exe")
|
||||
}
|
||||
|
||||
/// Human-readable interpreter name for spawn-failure context. On Windows this
|
||||
/// is the resolved PowerShell path so a missing/odd interpreter is obvious in
|
||||
/// the log (the old message only printed the script path, which read as if the
|
||||
/// .ps1 itself was missing).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn interpreter_label() -> String {
|
||||
windows_powershell_exe().display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn interpreter_label() -> String {
|
||||
"bash".to_string()
|
||||
}
|
||||
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("ok").and_then(|v| v.as_bool()).is_some()
|
||||
&& value.get("stage").and_then(|v| v.as_str()).is_some()
|
||||
{
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<crate::events::StageResultPayload>(value)
|
||||
{
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Same logic but for the `-Manifest` payload (the LAST line with a `stages`
|
||||
/// array). Returns the parsed manifest.
|
||||
pub fn parse_manifest(stdout: &str) -> Option<crate::events::Manifest> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("stages").and_then(|v| v.as_array()).is_some() {
|
||||
if let Ok(parsed) = serde_json::from_value::<crate::events::Manifest>(value) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_stage_result_picks_last_json_line() {
|
||||
let stdout = r#"
|
||||
[bootstrap] some info
|
||||
{"ok": false, "stage": "venv", "reason": "bad python"}
|
||||
{"ok": true, "stage": "venv"}
|
||||
final non-json banner
|
||||
"#;
|
||||
let result = parse_stage_result(stdout).unwrap();
|
||||
assert_eq!(result.stage, "venv");
|
||||
assert!(result.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_finds_stages_array() {
|
||||
let stdout = r#"
|
||||
info line
|
||||
{"stages": [{"name": "uv", "title": "uv", "category": "prereqs", "needs_user_input": false}], "protocol_version": 1}
|
||||
"#;
|
||||
let m = parse_manifest(stdout).unwrap();
|
||||
assert_eq!(m.stages.len(), 1);
|
||||
assert_eq!(m.stages[0].name, "uv");
|
||||
assert_eq!(m.protocol_version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_match() {
|
||||
assert!(parse_stage_result("just banner\n").is_none());
|
||||
assert!(parse_manifest("just banner\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_script_cwd_prefers_existing_hermes_home() {
|
||||
let script = Path::new("/tmp/install.sh");
|
||||
let cwd = stable_script_cwd(script, Some("/"));
|
||||
assert_eq!(cwd, Some(Path::new("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_under_root_uses_system32_v1_layout() {
|
||||
let resolved = powershell_under_root(Path::new("C:\\Windows"));
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
normalized.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"),
|
||||
"unexpected powershell path: {normalized}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user