feat(installer): Tauri bootstrap installer for first-time onboarding

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.
This commit is contained in:
emozilla 2026-05-28 02:22:37 -04:00
parent 80d782bc78
commit 8eedb50bce
35 changed files with 3153 additions and 0 deletions

34
apps/bootstrap-installer/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# Rust / Cargo
/src-tauri/target/
/src-tauri/Cargo.lock
# Vite / build output
/dist/
/dist-ssr/
*.local
# Tauri generated artifacts (regenerated on each build)
/src-tauri/gen/schemas/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Node
node_modules/
# Internal placeholder (re-create if needed)
.tauri-note

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Setup</title>
</head>
<body class="h-full antialiased">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,46 @@
{
"name": "@hermes/bootstrap-installer",
"private": true,
"version": "0.0.1",
"description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5175",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@vscode/codicons": "^0.0.45",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"katex": "^0.16.45",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-shimmer": "^0.4.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
}

View file

@ -0,0 +1,75 @@
[package]
name = "hermes-bootstrap"
version = "0.0.1"
description = "Hermes Setup — signed installer that drives scripts/install.ps1"
authors = ["Nous Research <info@nousresearch.com>"]
edition = "2021"
rust-version = "1.77"
# Rename the output binary so the distributed artifact is literally
# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees
# what we hand her, period. Tauri honors [[bin]] over [package].name
# for the produced executable name.
[[bin]]
name = "Hermes-Setup"
path = "src/main.rs"
# The library target name MUST match the `withGlobalTauri` binding name that
# tauri.conf.json's `app.windows[].label` references. We don't ship a separate
# lib for now; everything is in src/.
[lib]
name = "hermes_bootstrap_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Tauri runtime + plugins
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
tauri-plugin-shell = "2"
# Async + IO
tokio = { version = "1", features = ["full"] }
futures = "0.3"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP — rustls so we don't need OpenSSL on the build box
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the
# webview console via Tauri's event channel.
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-appender = "0.2"
# Paths + utils
dirs = "5"
which = "6"
anyhow = "1"
thiserror = "1"
once_cell = "1"
uuid = { version = "1", features = ["v4"] }
# Process control on Windows (CREATE_NO_WINDOW etc.)
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Console",
"Win32_UI_WindowsAndMessaging",
] }
[profile.release]
# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

View file

@ -0,0 +1,150 @@
use std::process::Command;
fn main() {
// -----------------------------------------------------------------
// Bake the install.ps1 pin into the binary at compile time.
//
// BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's
// `option_env!()` macro to default the install-script reference.
// Precedence (matches install.ps1's own arg precedence): commit > branch.
//
// Resolution order:
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
// Useful for CI builds that want to pin to a tagged release SHA
// rather than whatever the checkout's HEAD happens to be.
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
// the repo this build.rs lives in. Default for `cargo tauri build`
// from a dev machine — pins the produced .exe to your current
// checkout state.
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
// installer will fetch HEAD-of-main at runtime. Used when the
// build is happening outside a git checkout (e.g. cargo install
// from a packaged crate, unlikely for this binary but defensive).
//
// Build script reruns on git HEAD change so a new commit triggers
// a rebuild without `cargo clean`.
// -----------------------------------------------------------------
let commit = resolve_commit_pin();
let branch = resolve_branch_pin();
if let Some(c) = &commit {
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
}
if let Some(b) = &branch {
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
}
if commit.is_none() && branch.is_none() {
// Fail loudly rather than silently produce a binary that errors
// at runtime with "no install-script pin supplied". A build that
// can't resolve a pin almost certainly indicates a misconfigured
// build environment.
println!(
"cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args"
);
}
// Rerun build.rs when HEAD moves so successive builds pick up new
// commits without needing `cargo clean`. .git/HEAD changes on every
// commit / branch switch / rebase.
let git_dir = locate_git_dir();
if let Some(gd) = &git_dir {
println!("cargo:rerun-if-changed={}/HEAD", gd.display());
// .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`);
// also watch the ref itself so a new commit on the same branch
// re-triggers.
if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) {
if let Some(rest) = head.trim().strip_prefix("ref: ") {
println!("cargo:rerun-if-changed={}/{}", gd.display(), rest);
}
}
}
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT");
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH");
// -----------------------------------------------------------------
// Tauri windows manifest. See hermes-setup.manifest for rationale —
// declares level="asInvoker" so Windows's installer-detection
// heuristic doesn't refuse to launch us without UAC elevation.
// -----------------------------------------------------------------
#[cfg(target_os = "windows")]
let attrs = {
let manifest = include_str!("hermes-setup.manifest");
let win = tauri_build::WindowsAttributes::new().app_manifest(manifest);
tauri_build::Attributes::new().windows_attributes(win)
};
#[cfg(not(target_os = "windows"))]
let attrs = tauri_build::Attributes::new();
tauri_build::try_build(attrs).expect("failed to run tauri-build");
}
fn resolve_commit_pin() -> Option<String> {
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn resolve_branch_pin() -> Option<String> {
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
// "HEAD" is what you get on a detached checkout — no meaningful branch
// to pin to. The commit pin still applies; just don't emit a branch.
if s.is_empty() || s == "HEAD" {
None
} else {
Some(s)
}
}
fn locate_git_dir() -> Option<std::path::PathBuf> {
let out = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
if s.is_empty() {
return None;
}
Some(std::path::PathBuf::from(s))
}
fn short(commit: &str) -> &str {
if commit.len() >= 12 {
&commit[..12]
} else {
commit
}
}

View file

@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/config/2/capability",
"identifier": "default",
"description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:event:default",
"opener:default",
"dialog:default",
"process:default",
"shell:default"
]
}

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Hermes Setup application manifest.
The TL;DR: tell Windows we are NOT an installer in the classic "needs
UAC elevation" sense, despite the product name. We provision into
%LOCALAPPDATA%\hermes which is user-scoped and never touch HKLM or
Program Files. install.ps1 runs as a child process and elevates
itself only if a future stage explicitly needs HKLM access.
Without this manifest, the "Hermes Setup" productName embedded in
the binary's resource trips Windows's installer-detection heuristic
(https://learn.microsoft.com/en-us/windows/security/identity-protection/
user-account-control/how-user-account-control-works#installer-detection)
and CreateProcess fails with ERROR_ELEVATION_REQUIRED (740) when the
user double-clicks. asInvoker disables that.
-->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="0.0.1.0"
processorArchitecture="*"
name="NousResearch.Hermes.Setup"
type="win32"
/>
<description>Hermes Setup</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<!-- Tell Windows we know about all supported OSes (10 + 11) so it
doesn't shim us into Vista-compat mode. -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
<!-- Windows Vista -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
</application>
</compatibility>
<!-- Per-monitor v2 DPI awareness so the installer doesn't go blurry
on high-DPI displays when dragged between monitors. -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
<!-- Use the modern common controls (v6 themes). Without this, our
file picker / shell dialogs fall back to 1990s-era visuals. -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -0,0 +1,619 @@
//! 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, 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`.
#[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(|| "Could not locate a built Hermes desktop binary".to_string())?;
tracing::info!(?exe_path, "launching Hermes desktop");
// Detach from us — the installer is about to exit.
let mut cmd = tokio::process::Command::new(&exe_path);
cmd.current_dir(exe_path.parent().unwrap_or(&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>).
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
}
// ---------------------------------------------------------------------------
// 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(),
},
);
tracing::debug!(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
let manifest_args = build_pin_args(&script);
let mut manifest_args_full = vec!["-Manifest".to_string()];
manifest_args_full.extend(manifest_args.clone());
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");
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 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(),
},
);
}),
on_stderr_line: Box::new(move |line: &str| {
emit_event(
&app_for_stderr,
BootstrapEvent::Log {
stage: stage_for_stderr.clone(),
line: format!("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) {
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])
}
}

View file

@ -0,0 +1,99 @@
//! 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,
}
/// 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).
Log {
#[serde(skip_serializing_if = "Option::is_none")]
stage: Option<String>,
line: String,
},
/// 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";
}

View file

@ -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");
}
}

View file

@ -0,0 +1,66 @@
//! 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;
use std::sync::Arc;
use tokio::sync::Mutex;
/// 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>>,
}
impl Default for AppState {
fn default() -> Self {
Self {
bootstrap: Mutex::new(None),
}
}
}
#[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();
tracing::info!("Hermes Setup starting");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.manage(Arc::new(AppState::default()))
.invoke_handler(tauri::generate_handler![
// Bootstrap lifecycle
bootstrap::start_bootstrap,
bootstrap::cancel_bootstrap,
bootstrap::get_bootstrap_status,
// 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");
}

View file

@ -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()
}

View file

@ -0,0 +1,119 @@
//! Filesystem paths + logging setup.
//!
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
//! Windows: %LOCALAPPDATA%\hermes
//! macOS: ~/Library/Application Support/hermes
//! Linux: ~/.hermes (XDG override via $HERMES_HOME)
//!
//! 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};
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");
}
}
#[cfg(target_os = "macos")]
{
// ~/Library/Application Support/hermes
if let Some(home) = dirs::home_dir() {
return home.join("Library/Application Support/hermes");
}
}
// Linux + fallback: ~/.hermes
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")
}
/// 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())
}

View file

@ -0,0 +1,267 @@
//! 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);
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 {}", script_path.display()))?;
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,
})
}
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).
let mut cmd = Command::new("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
}
/// 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());
}
}

View file

@ -0,0 +1,67 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermes Setup",
"version": "0.0.1",
"identifier": "com.nousresearch.hermes.setup",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:5175",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Hermes Setup",
"width": 880,
"height": 620,
"minWidth": 720,
"minHeight": 520,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false,
"center": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
},
"withGlobalTauri": false
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"shortDescription": "Hermes Setup",
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
"publisher": "Nous Research",
"copyright": "Copyright © 2026 Nous Research",
"targets": [
"app",
"dmg",
"appimage"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"webviewInstallMode": {
"type": "embedBootstrapper"
}
},
"macOS": {
"minimumSystemVersion": "11.0",
"hardenedRuntime": true
}
},
"plugins": {
"shell": {
"open": true
}
}
}

View file

@ -0,0 +1,35 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { $route, $bootstrap, initialize } from './store'
import Welcome from './routes/welcome'
import Progress from './routes/progress'
import Success from './routes/success'
import Failure from './routes/failure'
/*
* App shell Hermes Setup.
*
* No header chrome (the OS title bar already says "Hermes Setup"; an
* in-window repeat of the H mark + words was redundant slop).
*
* Route state lives in a single $route atom 4 screens, no react-router.
*/
export default function App() {
const route = useStore($route)
const bootstrap = useStore($bootstrap)
useEffect(() => {
void initialize()
}, [])
return (
<div className="relative flex h-full flex-col overflow-hidden bg-background text-foreground">
<main className="relative z-10 flex flex-1 flex-col overflow-hidden">
{route === 'welcome' && <Welcome />}
{route === 'progress' && <Progress bootstrap={bootstrap} />}
{route === 'success' && <Success />}
{route === 'failure' && <Failure bootstrap={bootstrap} />}
</main>
</div>
)
}

View file

@ -0,0 +1,80 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { cn } from '../lib/utils'
/*
* Button copied verbatim from apps/desktop/src/components/ui/button.tsx.
*
* We import the desktop's local shadcn-style Button rather than
* @nous-research/ui's <Button>, because the DS Button uses bg-midground /
* text-background-base utilities that resolve to the DS's hardcoded
* gold/brown brand defaults (#ffac02 / #170d02) unless overridden in
* runtime. The desktop never sets those vars; it routes through its
* own --dt-* token chain via shadcn classes like bg-primary. We do
* the same so visuals match exactly.
*/
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs':
"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
interface ButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
data-size={size}
data-slot="button"
data-variant={variant}
{...props}
/>
)
}
export { buttonVariants }

View file

@ -0,0 +1,12 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/*
* cn Tailwind-aware class merger. Same util the desktop and dashboard
* use. clsx handles conditional classes; twMerge resolves utility
* conflicts so `cn('px-2', condition && 'px-4')` ends up with px-4 only,
* not both.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './app.tsx'
import './styles.css'
// Default to LIGHT mode — matches the Hermes desktop's default. The
// desktop's runtime theme system can switch to .dark later, but our
// installer ships in light mode only since we don't carry the theme
// provider machinery.
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View file

@ -0,0 +1,77 @@
import { type CSSProperties } from 'react'
import { useStore } from '@nanostores/react'
import { Button } from '../components/button'
import {
$logPath,
openLogDir,
startInstall,
type BootstrapStateModel
} from '../store'
import { RefreshCw, FileText } from 'lucide-react'
interface FailureProps {
bootstrap: BootstrapStateModel
}
/*
* Failure screen. Same hero treatment as Welcome/Success the wordmark
* carries the brand, so we keep it across every terminal state.
*
* The actual error message lives below in muted text. Two clear
* affordances: Retry (primary) and Open log folder (secondary).
*/
export default function Failure({ bootstrap }: FailureProps) {
const logPath = useStore($logPath)
return (
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
<div className="w-full max-w-2xl min-w-0 text-center">
<p
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-destructive mix-blend-plus-lighter dark:text-destructive/90"
style={
{
'--fit-text-line-height': '0.9',
'--fit-text-max': '5rem',
'--fit-text-min': '2.25rem'
} as CSSProperties
}
>
<span>
<span>Install didn&rsquo;t finish</span>
</span>
<span aria-hidden="true">Install didn&rsquo;t finish</span>
</p>
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
{bootstrap.error ?? 'Something went wrong during installation.'}
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => void startInstall()}
size="lg"
className="inline-flex items-center gap-2 px-6"
>
<RefreshCw size={16} />
Retry install
</Button>
<Button
variant="outline"
size="lg"
onClick={() => void openLogDir()}
className="inline-flex items-center gap-2"
>
<FileText size={16} />
Open log folder
</Button>
</div>
{logPath && (
<p className="max-w-lg text-center text-xs text-muted-foreground/70">
Log: <code className="font-mono">{logPath}</code>
</p>
)}
</div>
)
}

View file

@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '@nanostores/react'
import { Button } from '../components/button'
import {
cancelInstall,
$progress,
type BootstrapStateModel,
type StageState
} from '../store'
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
import clsx from 'clsx'
interface ProgressProps {
bootstrap: BootstrapStateModel
}
/*
* Progress screen drives a stage list + collapsible log panel. Uses
* the DS <Progress> for the top bar so its motion + ring match the rest
* of the product.
*/
export default function ProgressScreen({ bootstrap }: ProgressProps) {
const progress = useStore($progress)
const [showLogs, setShowLogs] = useState(false)
const logEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (showLogs && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [bootstrap.logs.length, showLogs])
const currentStage =
bootstrap.currentStage != null
? bootstrap.stages[bootstrap.currentStage]
: null
return (
<div className="hermes-fade-in flex h-full flex-col">
<div className="border-b border-border px-6 py-4">
<div className="mb-3 flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-foreground">
{bootstrap.status === 'running' && (
<Loader2 size={12} className="animate-spin text-primary" />
)}
<span>
{bootstrap.status === 'running'
? currentStage
? currentStage.info.title
: 'Preparing\u2026'
: bootstrap.status === 'completed'
? 'Done'
: 'Installing'}
</span>
</div>
<div className="text-muted-foreground">
{progress.done} of {progress.total} steps
</div>
</div>
{/* Top progress bar plain HTML, derived from --primary so it
tracks the theme accent. */}
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
/>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-4">
<ol className="space-y-1">
{bootstrap.stageOrder.map((name) => {
const rec = bootstrap.stages[name]
if (!rec) return null
return (
<li
key={name}
className={clsx(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
rec.state === 'running' && 'bg-card text-foreground',
rec.state === 'succeeded' && 'text-foreground/80',
rec.state === 'skipped' && 'text-muted-foreground',
rec.state === 'failed' &&
'bg-destructive/10 text-destructive',
!rec.state && 'text-muted-foreground/60'
)}
>
<StateIcon state={rec.state ?? null} />
<span className="flex-1 truncate">{rec.info.title}</span>
{rec.durationMs != null && (
<span className="text-xs text-muted-foreground">
{formatDuration(rec.durationMs)}
</span>
)}
</li>
)
})}
</ol>
</div>
{showLogs && (
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<div className="text-xs font-medium text-foreground/80">
Live output
</div>
<div className="text-xs text-muted-foreground">
{bootstrap.logs.length} lines
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
{bootstrap.logs.map((entry, idx) => (
<div
key={idx}
className={clsx(
'whitespace-pre-wrap',
entry.line.startsWith('stderr:')
? 'text-destructive'
: 'text-foreground/70'
)}
>
{entry.line}
</div>
))}
<div ref={logEndRef} />
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
<button
type="button"
onClick={() => setShowLogs((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<FileText size={14} />
{showLogs ? 'Hide details' : 'Show details'}
<ChevronRight
size={12}
className={clsx(
'transition-transform',
showLogs && 'rotate-90'
)}
/>
</button>
{bootstrap.status === 'running' && (
<Button
variant="outline"
size="sm"
onClick={() => void cancelInstall()}
>
Cancel
</Button>
)}
</div>
</div>
)
}
function StateIcon({ state }: { state: StageState | null }) {
if (state === 'running') {
return <Loader2 size={14} className="animate-spin text-primary" />
}
if (state === 'succeeded') {
return <Check size={14} className="text-emerald-400" />
}
if (state === 'skipped') {
return <ChevronRight size={14} className="text-muted-foreground/70" />
}
if (state === 'failed') {
return <X size={14} className="text-destructive" />
}
return (
<div
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
aria-hidden
/>
)
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
const m = Math.floor(ms / 60000)
const s = Math.round((ms % 60000) / 1000)
return `${m}m ${s}s`
}

View file

@ -0,0 +1,52 @@
import { type CSSProperties } from 'react'
import { Button } from '../components/button'
import { launchHermesDesktop } from '../store'
import { Rocket } from 'lucide-react'
/*
* Success screen. HERMES AGENT wordmark stays as the visual anchor
* (same Collapse Bold treatment as Welcome + the desktop chat intro),
* with a status line below.
*
* No install-path footer same rationale as Welcome.
*/
export default function Success() {
return (
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-8 px-12 py-10">
<div className="w-full max-w-2xl min-w-0 text-center">
<p
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={
{
'--fit-text-line-height': '0.9',
'--fit-text-max': '5rem',
'--fit-text-min': '2.25rem'
} as CSSProperties
}
>
<span>
<span>Hermes is ready</span>
</span>
<span aria-hidden="true">Hermes is ready</span>
</p>
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
You can launch from here, or any time from your terminal with{' '}
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-sm">
hermes desktop
</code>
.
</p>
</div>
<Button
onClick={() => void launchHermesDesktop()}
size="lg"
className="inline-flex items-center gap-2 px-6"
>
<Rocket size={18} />
Launch Hermes
</Button>
</div>
)
}

View file

@ -0,0 +1,58 @@
import { type CSSProperties } from 'react'
import { Button } from '../components/button'
import { startInstall } from '../store'
import { ArrowRight } from 'lucide-react'
/*
* Welcome screen.
*
* Mirrors the desktop's chat intro (apps/desktop/src/components/chat/intro.tsx):
* - HERMES AGENT wordmark rendered in Collapse Bold, uppercase, tracked
* - mix-blend-plus-lighter so the type "glows" on the canvas
* - fit-text utility so the wordmark sizes itself to the column
*
* No install-path footer. The default install location is correct for
* 99% of users; the rest will use the CLI installer with a -HermesHome
* flag. Showing %LOCALAPPDATA% to grandma is developer-brain.
*/
export default function Welcome() {
return (
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-10 px-12 py-10">
{/* Hero — same recipe the desktop's chat/intro.tsx uses */}
<div className="w-full max-w-2xl min-w-0 text-center">
<p
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={
{
'--fit-text-line-height': '0.9',
'--fit-text-max': '6rem',
'--fit-text-min': '2.5rem'
} as CSSProperties
}
>
<span>
<span>HERMES AGENT</span>
</span>
<span aria-hidden="true">HERMES AGENT</span>
</p>
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
The agent that grows with you. We&rsquo;ll set things up in the
background &mdash; takes a few minutes.
</p>
</div>
<Button
onClick={() => void startInstall()}
size="lg"
className="group inline-flex items-center gap-2 px-6"
>
Install Hermes
<ArrowRight
size={18}
className="transition-transform group-hover:translate-x-0.5"
/>
</Button>
</div>
)
}

View file

@ -0,0 +1,247 @@
import { atom, computed } from 'nanostores'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
/*
* Bootstrap state store single source of truth for installer screens.
*
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
* AGENTS.md): "Prefer small nanostores over component state when state is
* shared, reused, or read by distant UI."
*
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
* We translate those events into typed atom updates here so the rest of
* the app only deals with React-friendly state.
*/
// ---------------------------------------------------------------------------
// Types — mirror src-tauri/src/events.rs
// ---------------------------------------------------------------------------
export interface StageInfo {
name: string
title: string
category: string
needs_user_input: boolean
}
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
export interface StageRecord {
info: StageInfo
state: StageState | null
durationMs?: number
error?: string
}
export interface BootstrapStateModel {
status: 'idle' | 'running' | 'completed' | 'failed'
protocolVersion: number | null
stages: Record<string, StageRecord>
stageOrder: string[]
currentStage: string | null
installRoot: string | null
error: string | null
logs: Array<{ stage?: string; line: string }>
}
const INITIAL: BootstrapStateModel = {
status: 'idle',
protocolVersion: null,
stages: {},
stageOrder: [],
currentStage: null,
installRoot: null,
error: null,
logs: []
}
// ---------------------------------------------------------------------------
// Atoms
// ---------------------------------------------------------------------------
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
export const $route = atom<Route>('welcome')
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
export const $logPath = atom<string | null>(null)
export const $hermesHome = atom<string | null>(null)
export const $progress = computed($bootstrap, (b) => {
const total = b.stageOrder.length
if (total === 0) return { done: 0, total: 0, fraction: 0 }
let done = 0
for (const name of b.stageOrder) {
const s = b.stages[name]?.state
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
}
return { done, total, fraction: done / total }
})
// ---------------------------------------------------------------------------
// Tauri event subscription
// ---------------------------------------------------------------------------
interface BootstrapManifestEvent {
type: 'manifest'
stages: StageInfo[]
protocolVersion: number | null
}
interface BootstrapStageEvent {
type: 'stage'
name: string
state: StageState
durationMs?: number
error?: string
}
interface BootstrapLogEvent {
type: 'log'
stage?: string
line: string
}
interface BootstrapCompleteEvent {
type: 'complete'
installRoot: string
marker: unknown
}
interface BootstrapFailedEvent {
type: 'failed'
stage?: string
error: string
}
type BootstrapEvent =
| BootstrapManifestEvent
| BootstrapStageEvent
| BootstrapLogEvent
| BootstrapCompleteEvent
| BootstrapFailedEvent
let unlisten: UnlistenFn | null = null
export async function initialize(): Promise<void> {
if (unlisten) return
// Pull static info on mount for the diagnostics footer.
try {
const [logPath, hermesHome] = await Promise.all([
invoke<string>('get_log_path'),
invoke<string>('get_hermes_home')
])
$logPath.set(logPath)
$hermesHome.set(hermesHome)
} catch (err) {
console.warn('failed to fetch installer paths', err)
}
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
const payload = event.payload
const cur = $bootstrap.get()
switch (payload.type) {
case 'manifest': {
const stages: Record<string, StageRecord> = {}
const order: string[] = []
for (const s of payload.stages) {
stages[s.name] = { info: s, state: null }
order.push(s.name)
}
$bootstrap.set({
...cur,
status: 'running',
protocolVersion: payload.protocolVersion,
stages,
stageOrder: order,
currentStage: null,
installRoot: null,
error: null,
logs: []
})
$route.set('progress')
break
}
case 'stage': {
const existing = cur.stages[payload.name]
if (!existing) {
console.warn('stage event for unknown stage', payload.name)
break
}
const next: StageRecord = {
...existing,
state: payload.state,
durationMs: payload.durationMs,
error: payload.error
}
$bootstrap.set({
...cur,
stages: { ...cur.stages, [payload.name]: next },
currentStage:
payload.state === 'running' ? payload.name : cur.currentStage
})
break
}
case 'log': {
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
// during a long install (playwright chromium download is ~10k lines).
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
$bootstrap.set({ ...cur, logs: trimmed })
break
}
case 'complete':
$bootstrap.set({
...cur,
status: 'completed',
installRoot: payload.installRoot,
currentStage: null
})
$route.set('success')
break
case 'failed':
$bootstrap.set({
...cur,
status: 'failed',
error: payload.error,
currentStage: null
})
$route.set('failure')
break
}
})
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
export async function startInstall(opts?: { branch?: string }): Promise<void> {
// Reset before kicking off so a retry from the failure screen clears
// the previous run's state.
$bootstrap.set(INITIAL)
$route.set('progress')
await invoke('start_bootstrap', {
args: {
commit: null,
branch: opts?.branch ?? null,
include_desktop: true,
hermes_home: null
}
})
}
export async function cancelInstall(): Promise<void> {
await invoke('cancel_bootstrap')
}
export async function launchHermesDesktop(): Promise<void> {
const installRoot = $bootstrap.get().installRoot
if (!installRoot) throw new Error('no install root')
await invoke('launch_hermes_desktop', { installRoot })
}
export async function openLogDir(): Promise<void> {
await invoke('open_log_dir')
}

View file

@ -0,0 +1,51 @@
/*
* Hermes Setup defer entirely to the desktop's styles.css.
*
* Rather than re-implement the Hermes design system (and inevitably drift
* from it), we import apps/desktop/src/styles.css wholesale. The desktop
* is the canonical source of truth for fonts, color tokens, button chrome,
* scrollbars, layout utilities, and animations. Any change to the
* Hermes look propagates here automatically with no copy-paste maintenance.
*
* Path resolution caveats:
* - Tailwind v4's `@import` resolves relative to this file. The desktop's
* `@source '../../../node_modules/...'` declarations therefore re-resolve
* against apps/bootstrap-installer/src/. Since both apps live two levels
* deep under the same repo root, `../../../node_modules` lands in the
* same place. (Verify if either app ever moves.)
* - The desktop's `@font-face url('../../../node_modules/...')` references
* are baked into the *imported* stylesheet; CSS resolves url()s relative
* to the file that contains them, so they continue to point at the
* correct node_modules path even from here.
*
* Forced light mode: the desktop ships with a runtime theme switcher
* (ThemeProvider + applyTheme) that can flip to dark via document.documentElement.
* The installer has no UI for theme switching, so we stay on the desktop's
* default light surface (Nous-blue accent on near-white chrome).
*/
@import '../../desktop/src/styles.css';
/* Installer-only additions: a fade-in animation and a warm radial glow
for the welcome screen. Everything else inherits from the desktop. */
@keyframes hermes-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hermes-fade-in {
animation: hermes-fade-in 0.45s ease-out both;
}
.hermes-glow {
background: radial-gradient(
ellipse at center,
color-mix(in srgb, var(--ui-warm) 18%, transparent) 0%,
transparent 60%
);
}

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,46 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
// Hermes Setup — Tauri-targeted Vite config.
//
// Port 5175 keeps us out of the way of:
// apps/dashboard (vite default 5173)
// apps/desktop dev (5174 per its package.json)
//
// `clearScreen: false` is the Tauri convention — they spawn vite as a child
// process and want our errors to stay visible.
const host = process.env.TAURI_DEV_HOST
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
clearScreen: false,
server: {
port: 5175,
strictPort: true,
host: host || '127.0.0.1',
hmr: host
? {
protocol: 'ws',
host,
port: 5176
}
: undefined,
watch: {
// Don't watch the Rust side — tauri-cli handles it.
ignored: ['**/src-tauri/**']
}
},
build: {
target: 'esnext',
outDir: 'dist',
emptyOutDir: true
}
})

316
package-lock.json generated
View file

@ -21,6 +21,40 @@
"node": ">=20.0.0"
}
},
"apps/bootstrap-installer": {
"name": "@hermes/bootstrap-installer",
"version": "0.0.1",
"dependencies": {
"@nous-research/ui": "0.16.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@vscode/codicons": "^0.0.45",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"katex": "^0.16.45",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-shimmer": "^0.4.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
},
"apps/dashboard": {
"version": "0.0.0",
"dependencies": {
@ -2639,6 +2673,10 @@
"@hapi/hoek": "^11.0.2"
}
},
"node_modules/@hermes/bootstrap-installer": {
"resolved": "apps/bootstrap-installer",
"link": true
},
"node_modules/@hermes/shared": {
"resolved": "apps/shared",
"link": true
@ -7857,6 +7895,284 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",