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