Download apps/bootstrap-installer/src-tauri/src/install_script.rs from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/bootstrap-installer/src-tauri/src/install_script.rs
- Command line
-
hf download hf://SaylorTwift/hermes-agent/apps/bootstrap-installer/src-tauri/src/install_script.rs
-
curl -L -o install_script.rs https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/bootstrap-installer/src-tauri/src/install_script.rs
17.9 kB
| //! 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.ts`'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. | |
| 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>, | |
| } | |
| pub enum ScriptSource { | |
| DevCheckout, | |
| Bundled, | |
| Cached, | |
| Downloaded, | |
| } | |
| /// What flavor of script (Windows .ps1 vs Unix .sh). | |
| 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.ts. | |
| fn is_valid_commit(s: &str) -> bool { | |
| let len = s.len(); | |
| (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) | |
| } | |
| /// Resolver cache plan for a pin that already has a local path computed. | |
| /// | |
| /// Immutable commit pins reuse cache forever. Mutable branch/tag pins always | |
| /// refresh, and only fall back to a stale cache when the refresh fails. | |
| pub(crate) enum CachePlan { | |
| /// On-disk hit for an immutable pin — skip the network. | |
| Reuse, | |
| /// Download (or re-download). `stale_ok` means a failed refresh may return | |
| /// the existing cache file (mutable pins with a prior download). | |
| Fetch { stale_ok: bool }, | |
| } | |
| pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan { | |
| if immutable && cached_exists { | |
| CachePlan::Reuse | |
| } else { | |
| CachePlan::Fetch { | |
| stale_ok: !immutable && cached_exists, | |
| } | |
| } | |
| } | |
| /// 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. | |
| // | |
| // Commit SHAs are immutable — permanent cache reuse is safe. | |
| // Branch/tag pins are moving refs: always try to refresh so "Retry install" | |
| // cannot keep reusing a poisoned install-main.ps1 forever (#67193). | |
| let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) { | |
| (Some(c), _) if is_valid_commit(c) => (c.clone(), true), | |
| (_, Some(b)) if !b.trim().is_empty() => (b.clone(), false), | |
| (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); | |
| match cache_plan(immutable, cached.exists()) { | |
| CachePlan::Reuse => { | |
| emit_log(&format!( | |
| "[bootstrap] using cached {} for {}", | |
| kind.filename(), | |
| truncate_ref(&commit_or_ref) | |
| )); | |
| // Immutable pins are cached forever, so a .ps1 cached by a | |
| // pre-BOM-fix installer would keep the #67193 encoding bug on | |
| // every retry. Upgrade it in place before handing it out. | |
| upgrade_cached_script(kind, &cached, emit_log); | |
| return Ok(ResolvedScript { | |
| path: cached, | |
| source: ScriptSource::Cached, | |
| commit: pin.commit.clone(), | |
| branch: pin.branch.clone(), | |
| }); | |
| } | |
| CachePlan::Fetch { stale_ok } => { | |
| emit_log(&format!( | |
| "[bootstrap] downloading {} for {} {} from GitHub", | |
| kind.filename(), | |
| if immutable { | |
| "commit" | |
| } else { | |
| "mutable ref" | |
| }, | |
| truncate_ref(&commit_or_ref) | |
| )); | |
| match download(kind, &commit_or_ref, &cached).await { | |
| Ok(()) => { | |
| emit_log(&format!("[bootstrap] cached to {}", cached.display())); | |
| Ok(ResolvedScript { | |
| path: cached, | |
| source: ScriptSource::Downloaded, | |
| commit: pin.commit.clone(), | |
| branch: pin.branch.clone(), | |
| }) | |
| } | |
| Err(err) if stale_ok => { | |
| emit_log(&format!( | |
| "[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}", | |
| truncate_ref(&commit_or_ref), | |
| kind.filename(), | |
| cached.display() | |
| )); | |
| // Stale cache can predate the BOM fix too — upgrade it. | |
| upgrade_cached_script(kind, &cached, emit_log); | |
| Ok(ResolvedScript { | |
| path: cached, | |
| source: ScriptSource::Cached, | |
| commit: pin.commit.clone(), | |
| branch: pin.branch.clone(), | |
| }) | |
| } | |
| Err(err) => Err(err), | |
| } | |
| } | |
| } | |
| } | |
| 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 | |
| } | |
| } | |
| /// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system | |
| /// ANSI code page; a leading BOM is what tells it the file is UTF-8. The | |
| /// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the | |
| /// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite | |
| /// (#67193). | |
| const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF]; | |
| /// Prepare bytes for the on-disk bootstrap cache. | |
| /// | |
| /// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files | |
| /// are left unchanged — a BOM would break `#!/bin/bash`. | |
| pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> { | |
| match kind { | |
| ScriptKind::Ps1 => { | |
| if bytes.starts_with(UTF8_BOM) { | |
| bytes.to_vec() | |
| } else { | |
| let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len()); | |
| out.extend_from_slice(UTF8_BOM); | |
| out.extend_from_slice(bytes); | |
| out | |
| } | |
| } | |
| ScriptKind::Sh => bytes.to_vec(), | |
| } | |
| } | |
| /// Upgrade a cached script written by a pre-BOM-fix installer in place. | |
| /// | |
| /// `prepare_cached_script_bytes` only runs inside `download()`, but immutable | |
| /// commit pins (and the stale-fallback path) reuse the on-disk file without | |
| /// re-downloading — so a BOM-less `.ps1` cached before the #67193 fix would | |
| /// keep reproducing the ANSI-codepage parse failure on every retry. Rewrites | |
| /// through the same atomic tmp+rename shape as `download()`. Best-effort: a | |
| /// failed upgrade logs a warning and keeps the original file (which is no | |
| /// worse than the pre-existing behavior). | |
| fn upgrade_cached_script(kind: ScriptKind, cached: &Path, emit_log: &impl Fn(&str)) { | |
| if !matches!(kind, ScriptKind::Ps1) { | |
| return; | |
| } | |
| let bytes = match std::fs::read(cached) { | |
| Ok(b) => b, | |
| Err(err) => { | |
| emit_log(&format!( | |
| "[bootstrap] WARNING: could not read cached script {} for BOM check: {err}", | |
| cached.display() | |
| )); | |
| return; | |
| } | |
| }; | |
| if bytes.starts_with(UTF8_BOM) { | |
| return; | |
| } | |
| let upgraded = prepare_cached_script_bytes(kind, &bytes); | |
| let tmp = cached.with_extension("ps1.tmp"); | |
| let result = std::fs::write(&tmp, &upgraded).and_then(|()| std::fs::rename(&tmp, cached)); | |
| match result { | |
| Ok(()) => emit_log(&format!( | |
| "[bootstrap] upgraded cached {} with UTF-8 BOM (#67193)", | |
| cached.display() | |
| )), | |
| Err(err) => { | |
| let _ = std::fs::remove_file(&tmp); | |
| emit_log(&format!( | |
| "[bootstrap] WARNING: could not upgrade cached {} with UTF-8 BOM: {err}", | |
| cached.display() | |
| )); | |
| } | |
| } | |
| } | |
| /// Downloads to `dest_path` via reqwest with rustls. Atomically renames | |
| /// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache. | |
| /// | |
| /// The client carries explicit timeouts: mutable branch pins call this on | |
| /// EVERY run (#67193 cache-refresh fix), and the stale-cache fallback in | |
| /// `resolve()` only fires when this returns `Err`. Without a timeout, a | |
| /// black-holed connection (captive portal, hung proxy, silently dropped | |
| /// packets) never errors — the whole bootstrap would hang here instead of | |
| /// falling back to the cached script. | |
| 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::builder() | |
| .connect_timeout(std::time::Duration::from_secs(10)) | |
| .timeout(std::time::Duration::from_secs(60)) | |
| .build() | |
| .context("building download client")? | |
| .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 bytes = prepare_cached_script_bytes(kind, &bytes); | |
| 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(()) | |
| } | |
| mod tests { | |
| use super::*; | |
| 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("")); | |
| } | |
| 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"); | |
| } | |
| fn prepare_cached_ps1_prefixes_utf8_bom() { | |
| let out = prepare_cached_script_bytes(ScriptKind::Ps1, b"Write-Host hi\n"); | |
| assert!(out.starts_with(UTF8_BOM), "cached .ps1 must start with UTF-8 BOM"); | |
| assert_eq!(&out[UTF8_BOM.len()..], b"Write-Host hi\n"); | |
| } | |
| fn prepare_cached_ps1_does_not_double_bom() { | |
| let mut already = UTF8_BOM.to_vec(); | |
| already.extend_from_slice(b"x"); | |
| let out = prepare_cached_script_bytes(ScriptKind::Ps1, &already); | |
| assert_eq!(out, already); | |
| assert_eq!(out.windows(3).filter(|w| *w == UTF8_BOM).count(), 1); | |
| } | |
| fn prepare_cached_sh_stays_bomless() { | |
| let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/bin/bash\n"); | |
| assert!(!out.starts_with(UTF8_BOM)); | |
| assert_eq!(out, b"#!/bin/bash\n"); | |
| } | |
| fn commit_pins_are_immutable_branch_pins_are_not() { | |
| // Mirrors the resolve() immutable decision: SHA pins may reuse cache | |
| // forever; branch pins must refresh so Retry cannot keep a bad script. | |
| assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0")); | |
| assert!(!is_valid_commit("main")); | |
| assert!(!is_valid_commit("release/1.2.3")); | |
| } | |
| fn existing_branch_cache_plans_refresh_with_stale_fallback() { | |
| // Resolver-level: a prior install-main.ps1 must not short-circuit | |
| // Retry — mutable pins refresh, and only fall back if download fails. | |
| assert_eq!( | |
| cache_plan(/*immutable=*/ false, /*cached_exists=*/ true), | |
| CachePlan::Fetch { stale_ok: true } | |
| ); | |
| assert_eq!( | |
| cache_plan(/*immutable=*/ true, /*cached_exists=*/ true), | |
| CachePlan::Reuse | |
| ); | |
| assert_eq!( | |
| cache_plan(/*immutable=*/ false, /*cached_exists=*/ false), | |
| CachePlan::Fetch { stale_ok: false } | |
| ); | |
| assert_eq!( | |
| cache_plan(/*immutable=*/ true, /*cached_exists=*/ false), | |
| CachePlan::Fetch { stale_ok: false } | |
| ); | |
| } | |
| fn upgrade_cached_script_adds_bom_to_legacy_ps1() { | |
| // A .ps1 cached by a pre-#67193 installer has no BOM; the Reuse path | |
| // must upgrade it in place instead of serving the broken bytes forever. | |
| let dir = std::env::temp_dir().join(format!("hermes-bom-test-{}", std::process::id())); | |
| std::fs::create_dir_all(&dir).unwrap(); | |
| let cached = dir.join("install-abc1234.ps1"); | |
| std::fs::write(&cached, b"Write-Host legacy\n").unwrap(); | |
| upgrade_cached_script(ScriptKind::Ps1, &cached, &|_| {}); | |
| let bytes = std::fs::read(&cached).unwrap(); | |
| assert!(bytes.starts_with(UTF8_BOM), "legacy cache must gain a BOM"); | |
| assert_eq!(&bytes[UTF8_BOM.len()..], b"Write-Host legacy\n"); | |
| // Idempotent: a second pass must not double the BOM. | |
| upgrade_cached_script(ScriptKind::Ps1, &cached, &|_| {}); | |
| let again = std::fs::read(&cached).unwrap(); | |
| assert_eq!(again, bytes); | |
| std::fs::remove_dir_all(&dir).unwrap(); | |
| } | |
| fn upgrade_cached_script_leaves_sh_untouched() { | |
| let dir = std::env::temp_dir().join(format!("hermes-bom-sh-test-{}", std::process::id())); | |
| std::fs::create_dir_all(&dir).unwrap(); | |
| let cached = dir.join("install-main.sh"); | |
| std::fs::write(&cached, b"#!/bin/bash\n").unwrap(); | |
| upgrade_cached_script(ScriptKind::Sh, &cached, &|_| {}); | |
| assert_eq!(std::fs::read(&cached).unwrap(), b"#!/bin/bash\n"); | |
| std::fs::remove_dir_all(&dir).unwrap(); | |
| } | |
| } | |