mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
Hermes-Setup.exe is a small signed Rust+Tauri binary that drives
scripts/install.ps1 stage-by-stage with a native UI matching the
desktop's design language. Replaces the chicken-and-egg pattern of
shipping a 200MB Electron app whose first launch existed only to
run install.ps1.
The architecture:
Rust backend (src-tauri/):
bootstrap.rs orchestrator -- Tauri commands, stage iteration
install_script.rs resolve install.ps1 (dev checkout, cache, GitHub raw)
powershell.rs spawn powershell, line-stream stdout/stderr, parse JSON
events.rs BootstrapEvent types -- mirror bootstrap-runner.cjs
paths.rs HERMES_HOME resolution + tracing log setup
build.rs bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from
'git rev-parse HEAD' at compile time
React frontend (src/):
Tauri webview rendering 4 screens (welcome / progress / success /
failure), driven by nanostores subscribing to the Rust event stream.
Visual layer reuses the desktop's styles.css wholesale via @import
so the installer and desktop never drift visually.
Distribution:
targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The
raw target/release/Hermes-Setup.exe IS the artifact on Windows;
.dmg + .app on macOS; AppImage on Linux. One file, double-click,
no installer-installing-an-installer pattern.
Compile-time pinning:
build.rs reads 'git rev-parse HEAD' and emits
cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>.
bootstrap.rs's option_env!() picks these up so the binary fetches
install.ps1 from the exact SHA it was tested against. CI / release
builds can override via HERMES_BUILD_PIN_COMMIT env var.
Windows manifest:
hermes-setup.manifest declares level='asInvoker' so the
productName 'Hermes Setup' doesn't trip Windows's installer-
detection heuristic and refuse to launch without elevation.
Also declares PerMonitorV2 DPI + UTF-8 active code page + Common
Controls v6.
Limitations of this initial version:
* No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe
('More info -> Run anyway'). The downstream binaries it produces
(Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and
therefore don't carry MOTW, so they launch without SmartScreen
intervention. Cert procurement tracked separately.
* macOS and Linux build paths defined but untested -- Windows-only V1.
273 lines
8 KiB
Rust
273 lines
8 KiB
Rust
//! 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");
|
|
}
|
|
}
|