//! Self-update mechanism. //! //! Checks the hosted herdr.dev update manifest for newer versions. //! Manual `herdr update` downloads and installs the binary. //! Background checks only surface availability and release notes. //! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies. //! JSON parsing uses serde_json (already in deps for persistence). use std::collections::BTreeMap; use std::env; use std::fs; #[cfg(not(windows))] use std::io; #[cfg(not(windows))] use std::io::{BufRead, BufReader, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(not(windows))] use std::time::{Duration, Instant}; #[cfg(not(windows))] use interprocess::local_socket::traits::Stream as _; use serde::{Deserialize, Deserializer}; const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json"; const HERDR_UPDATE_COMMAND: &str = "herdr update"; const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr"; const MISE_UPDATE_COMMAND: &str = "mise upgrade herdr"; const NIX_UPDATE_COMMAND: &str = "update through Nix"; const MISE_INSTALLS_DIR_ENV: &str = "MISE_INSTALLS_DIR"; const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION"; const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION"; const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0"; #[cfg(not(windows))] const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(windows))] const SERVER_HANDOFF_REQUEST_TIMEOUT: Duration = Duration::from_secs(240); #[cfg(not(windows))] const SERVER_HANDOFF_CONFIRM_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(not(windows))] const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); fn fake_release_notes_body(version: &str) -> String { let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| DEFAULT_FAKE_UPDATE_NOTES_VERSION.to_string()); if let Some(notes) = crate::release_notes::load_preview_from_local_changelog(¬es_version) { return notes.body; } format!( "### Changed\n- Test update v{version} for local UI validation.\n\n### Notes\n- This is stub release-notes content generated by {FAKE_UPDATE_VERSION_ENV}." ) } // --------------------------------------------------------------------------- // Version // --------------------------------------------------------------------------- /// Parsed semver version for comparison. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Version { pub major: u32, pub minor: u32, pub patch: u32, } impl Version { pub fn parse(s: &str) -> Option { let s = s.strip_prefix('v').unwrap_or(s); let parts: Vec<&str> = s.split('.').collect(); if parts.len() != 3 { return None; } Some(Self { major: parts[0].parse().ok()?, minor: parts[1].parse().ok()?, patch: parts[2].parse().ok()?, }) } pub fn current() -> Self { Self::parse(crate::build_info::BASE_VERSION).expect("invalid CARGO_PKG_VERSION") } } impl std::fmt::Display for Version { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } // --------------------------------------------------------------------------- // Update manifest // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UpdateChannel { Stable, Preview, } impl UpdateChannel { fn configured() -> Self { match crate::config::Config::load().config.update.channel { crate::config::UpdateChannelConfig::Stable => Self::Stable, crate::config::UpdateChannelConfig::Preview => Self::Preview, } } fn as_str(self) -> &'static str { match self { Self::Stable => "stable", Self::Preview => "preview", } } } #[derive(Debug, Clone)] struct AssetRef { url: String, sha256: Option, #[cfg(windows)] format: Option, } impl<'de> Deserialize<'de> for AssetRef { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let value = serde_json::Value::deserialize(deserializer)?; match value { serde_json::Value::String(url) if !url.trim().is_empty() => Ok(Self { url: url.trim().to_string(), sha256: None, #[cfg(windows)] format: None, }), serde_json::Value::Object(mut object) => { let url = object .remove("url") .and_then(|value| value.as_str().map(str::to_string)) .ok_or_else(|| serde::de::Error::custom("asset object is missing url"))?; let sha256 = object .remove("sha256") .and_then(|value| value.as_str().map(str::to_string)); #[cfg(windows)] let format = object .remove("format") .and_then(|value| value.as_str().map(str::to_string)); if url.trim().is_empty() { return Err(serde::de::Error::custom("asset url must not be empty")); } Ok(Self { url: url.trim().to_string(), sha256: sha256.filter(|value| !value.trim().is_empty()), #[cfg(windows)] format: format.filter(|value| !value.trim().is_empty()), }) } _ => Err(serde::de::Error::custom( "asset must be a URL string or object with url", )), } } } #[cfg(windows)] impl AssetRef { fn package_format(&self) -> Result { let format = self .format .clone() .unwrap_or_else(|| { if self.url.to_ascii_lowercase().ends_with(".zip") { "zip" } else { "exe" } .into() }) .to_ascii_lowercase(); match format.as_str() { "zip" | "exe" => Ok(format), _ => Err(format!( "update manifest asset has unsupported format '{format}'" )), } } } #[derive(Deserialize)] struct UpdateManifest { version: String, #[cfg(not(windows))] endpoint_generation: Option, /// Thin-client protocol spoken by this release, when advertised by the manifest. #[cfg(not(windows))] protocol: Option, notes: String, assets: BTreeMap, #[serde(default)] sha256: BTreeMap, announcement: Option, #[serde(default, deserialize_with = "deserialize_manifest_releases")] releases: BTreeMap, } fn deserialize_manifest_releases<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; Ok(match value { Some(serde_json::Value::Object(object)) => object.into_iter().collect(), _ => BTreeMap::new(), }) } #[derive(Deserialize)] struct ManifestReleaseMetadata { notes: String, announcement: Option, } #[derive(Deserialize)] struct PreviewManifest { channel: String, base_version: String, build_id: String, commit: String, built_at: String, protocol: u32, endpoint_generation: Option, notes: String, assets: BTreeMap, #[serde(default)] builds: BTreeMap, } #[derive(Deserialize)] struct PreviewBuildMetadata { base_version: String, commit: String, built_at: String, protocol: u32, endpoint_generation: Option, assets: BTreeMap, } #[derive(Deserialize)] struct HomebrewFormula { versions: HomebrewFormulaVersions, } #[derive(Deserialize)] struct HomebrewFormulaVersions { stable: String, } impl UpdateManifest { #[cfg(all(test, unix))] fn download_url_for(&self, os: &str, arch: &str) -> Option { self.assets .get(&format!("{os}-{arch}")) .map(|asset| asset.url.clone()) } fn metadata_for_version(&self, version: &Version) -> Option { let version = version.to_string(); if self.version.trim_start_matches('v') == version { return Some(ManifestReleaseMetadata { notes: self.notes.clone(), announcement: self.announcement.clone(), }); } self.releases.get(&version).and_then(|release| { let metadata = serde_json::from_value::(release.clone()).ok()?; (!metadata.notes_body().is_empty()).then_some(metadata) }) } } impl ManifestReleaseMetadata { fn notes_body(&self) -> String { self.notes.trim().to_string() } } // --------------------------------------------------------------------------- // Release info // --------------------------------------------------------------------------- /// Information about an available update. #[derive(Debug, Clone)] struct ReleaseInfo { version: Version, identity: String, channel: UpdateChannel, build_id: Option, commit: Option, #[cfg(not(windows))] target_protocol: Option, #[cfg(not(windows))] target_endpoint_generation: Option, download_url: String, sha256: Option, #[cfg(windows)] package_format: String, notes_body: String, } impl ReleaseInfo { fn label(&self) -> &str { &self.identity } } fn fetch_update_manifest() -> Result { fetch_json_manifest(STABLE_UPDATE_MANIFEST_URL) } fn fetch_preview_manifest() -> Result { fetch_json_manifest(PREVIEW_UPDATE_MANIFEST_URL) } fn fetch_json_manifest(url: &str) -> Result where T: serde::de::DeserializeOwned, { let output = crate::noninteractive_process::curl_command() .args([ "-sfL", "--retry", "3", "--connect-timeout", "10", "--max-time", "20", url, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { return Err("failed to fetch update manifest".into()); } serde_json::from_slice(&output.stdout) .map_err(|e| format!("failed to parse update manifest JSON: {e}")) } fn handle_manifest_announcement(version: &str, value: Option<&serde_json::Value>) { let announcement = match value { Some(value) => match serde_json::from_value::< crate::product_announcements::ManifestAnnouncement, >(value.clone()) { Ok(announcement) => Some(announcement), Err(err) => { tracing::warn!("skipping invalid product announcement in update manifest: {err}"); None } }, None => None, }; if let Err(err) = crate::product_announcements::save_manifest_announcement(version, announcement.as_ref()) { tracing::warn!("failed to save product announcement: {err}"); } } fn release_info_from_manifest(manifest: &UpdateManifest) -> Result, String> { let current = Version::current(); let latest = Version::parse(&manifest.version) .ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?; if !stable_channel_should_install(&latest, ¤t, crate::build_info::is_preview()) { return Ok(None); // up to date } let metadata = manifest .metadata_for_version(&latest) .ok_or_else(|| format!("missing release metadata for v{latest}"))?; let notes_body = metadata.notes_body(); if notes_body.is_empty() { return Err("update manifest notes are empty".into()); } let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); let asset = manifest .assets .get(&asset_key) .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?; let download_url = asset.url.clone(); let sha256 = asset .sha256 .clone() .or_else(|| manifest.sha256.get(&asset_key).cloned()) .ok_or_else(|| { format!("update manifest asset {asset_key} is missing a SHA-256 checksum") })?; Ok(Some(ReleaseInfo { identity: latest.to_string(), version: latest, channel: UpdateChannel::Stable, build_id: None, commit: None, #[cfg(not(windows))] target_protocol: manifest.protocol, #[cfg(not(windows))] target_endpoint_generation: manifest.endpoint_generation, download_url, sha256: Some(sha256), #[cfg(windows)] package_format: asset.package_format()?, notes_body, })) } fn stable_channel_should_install( latest: &Version, current: &Version, installed_is_preview: bool, ) -> bool { installed_is_preview || latest > current } fn preview_display_version(base_version: &str, build_id: &str) -> String { format!( "{}-preview.{}", base_version.trim_start_matches('v'), build_id ) } fn release_info_from_preview_manifest( manifest: &PreviewManifest, ) -> Result, String> { if manifest.channel != "preview" { return Err(format!( "invalid preview manifest channel: {}", manifest.channel )); } let build_id = manifest.build_id.trim(); if build_id.is_empty() { return Err("preview manifest build_id is empty".into()); } if crate::build_info::is_preview() && crate::build_info::build_id().is_some_and(|current| current == build_id) { return Ok(None); } let version = Version::parse(&manifest.base_version).ok_or_else(|| { format!( "invalid base_version in preview manifest: {}", manifest.base_version ) })?; let notes_body = manifest.notes.trim().to_string(); if notes_body.is_empty() { return Err("preview manifest notes are empty".into()); } let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); if let Some(archived) = manifest.builds.get(build_id) { if archived.base_version != manifest.base_version || archived.commit != manifest.commit || archived.built_at != manifest.built_at || archived.protocol != manifest.protocol || archived.endpoint_generation != manifest.endpoint_generation { tracing::warn!( build_id, "preview manifest archived build metadata differs from top-level metadata" ); } } let asset = manifest .assets .get(&asset_key) .or_else(|| { manifest .builds .get(build_id) .and_then(|build| build.assets.get(&asset_key)) }) .ok_or_else(|| format!("no binary for {asset_key} in preview manifest"))?; let download_url = asset.url.clone(); Ok(Some(ReleaseInfo { identity: preview_display_version(&manifest.base_version, build_id), version, channel: UpdateChannel::Preview, build_id: Some(build_id.to_string()), commit: Some(manifest.commit.clone()), #[cfg(not(windows))] target_protocol: Some(manifest.protocol), #[cfg(not(windows))] target_endpoint_generation: manifest.endpoint_generation, download_url, sha256: asset.sha256.clone(), #[cfg(windows)] package_format: asset.package_format()?, notes_body, })) } /// Check the hosted update manifest for the latest release. Returns release info if newer. fn first_windows_stable_is_pending( manifest: &UpdateManifest, is_windows: bool, installed_is_preview: bool, ) -> bool { is_windows && installed_is_preview && !manifest.assets.contains_key("windows-x86_64") } fn check_latest() -> Result, String> { let channel = UpdateChannel::configured(); if channel == UpdateChannel::Preview { return release_info_from_preview_manifest(&fetch_preview_manifest()?); } let manifest = fetch_update_manifest()?; if first_windows_stable_is_pending(&manifest, cfg!(windows), crate::build_info::is_preview()) { tracing::info!("waiting for the first stable Windows release"); return Ok(None); } let release = release_info_from_manifest(&manifest)?; if let Some(release) = &release { if let Some(metadata) = manifest.metadata_for_version(&release.version) { handle_manifest_announcement( &release.version.to_string(), metadata.announcement.as_ref(), ); } } Ok(release) } fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result { let formula: HomebrewFormula = serde_json::from_slice(input) .map_err(|e| format!("failed to parse Homebrew formula JSON: {e}"))?; Version::parse(&formula.versions.stable).ok_or_else(|| { format!( "invalid stable version in Homebrew formula JSON: {}", formula.versions.stable ) }) } fn homebrew_update_from_formula_json( input: &[u8], current: &Version, ) -> Result, String> { let latest = parse_homebrew_formula_stable_version(input)?; if &latest <= current { return Ok(None); } Ok(Some(latest)) } fn check_homebrew_latest() -> Result, String> { let current = Version::current(); let output = crate::noninteractive_process::curl_command() .args([ "-sfL", "--retry", "2", "--connect-timeout", "5", "--max-time", "10", HOMEBREW_FORMULA_API_URL, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { return Err("failed to fetch Homebrew formula JSON".into()); } homebrew_update_from_formula_json(&output.stdout, ¤t) } // --------------------------------------------------------------------------- // Download + install // --------------------------------------------------------------------------- #[cfg(not(windows))] struct DownloadedUpdate { current_exe: PathBuf, tmp_path: Option, } #[cfg(not(windows))] impl Drop for DownloadedUpdate { fn drop(&mut self) { if let Some(tmp_path) = self.tmp_path.take() { let _ = fs::remove_file(tmp_path); } } } /// Download a release to a prepared executable temp file without touching the running server. #[cfg(not(windows))] fn download_update(release: &ReleaseInfo) -> Result { let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?; let parent = current_exe.parent().ok_or("can't find binary directory")?; // Check write permissions early let test_path = parent.join(".herdr-write-test"); if let Err(e) = fs::write(&test_path, b"") { let _ = fs::remove_file(&test_path); return Err(format!( "install directory not writable: {} ({}). Try running with appropriate permissions.", parent.display(), e )); } let _ = fs::remove_file(&test_path); // Unique temp file (avoids races with concurrent instances) let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id())); // Download the exact asset URL (pinned to the release we checked) let status = crate::noninteractive_process::curl_command() .args(["-sfL", "--max-time", "120", "-o"]) .arg(&tmp_path) .arg(&release.download_url) .status() .map_err(|e| format!("download failed: {e}"))?; if !status.success() { let _ = fs::remove_file(&tmp_path); return Err("download failed".into()); } if let Some(expected) = &release.sha256 { if let Err(e) = crate::checksum::verify_sha256(&tmp_path, expected) { let _ = fs::remove_file(&tmp_path); return Err(format!( "downloaded update checksum verification failed: {e}" )); } tracing::info!(sha256 = %expected, "downloaded update checksum verified"); } // Make executable #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) { let _ = fs::remove_file(&tmp_path); return Err(format!("chmod failed: {e}")); } } Ok(DownloadedUpdate { current_exe, tmp_path: Some(tmp_path), }) } #[cfg(not(windows))] fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> { let tmp_path = update .tmp_path .take() .ok_or("downloaded update temp file is missing")?; // Atomic replace — rename over the current binary. // On Linux, the running process keeps its fd to the old inode. // Next launch picks up the new file. if let Err(e) = fs::rename(&tmp_path, &update.current_exe) { let _ = fs::remove_file(&tmp_path); return Err(format!("failed to replace binary: {e}")); } Ok(()) } pub(crate) const WINDOWS_INSTALLER: &str = include_str!("../distribution/install.ps1"); #[cfg(windows)] struct DownloadedWindowsUpdate { package_path: PathBuf, installer_path: PathBuf, } #[cfg(windows)] impl Drop for DownloadedWindowsUpdate { fn drop(&mut self) { let _ = fs::remove_file(&self.package_path); let _ = fs::remove_file(&self.installer_path); } } #[cfg(windows)] fn download_windows_update(release: &ReleaseInfo) -> Result { let expected_sha256 = release .sha256 .as_deref() .ok_or("Windows update asset is missing a SHA-256 checksum")?; let stem = format!("herdr-update-{}", std::process::id()); let update = DownloadedWindowsUpdate { package_path: env::temp_dir().join(format!("{stem}.{}", release.package_format)), installer_path: env::temp_dir().join(format!("{stem}.ps1")), }; fs::write(&update.installer_path, WINDOWS_INSTALLER) .map_err(|err| format!("failed to prepare Windows installer: {err}"))?; let status = crate::noninteractive_process::curl_command() .args(["-sfL", "--max-time", "120", "-o"]) .arg(&update.package_path) .arg(&release.download_url) .status() .map_err(|err| format!("download failed: {err}"))?; if !status.success() { return Err("download failed".into()); } crate::checksum::verify_sha256(&update.package_path, expected_sha256) .map_err(|err| format!("downloaded update checksum verification failed: {err}"))?; tracing::info!(sha256 = %expected_sha256, "downloaded update checksum verified"); Ok(update) } #[cfg(windows)] fn install_windows_update_with_installer( release: &ReleaseInfo, update: &DownloadedWindowsUpdate, ) -> Result<(), String> { let expected_sha256 = release .sha256 .as_deref() .ok_or("Windows update asset is missing a SHA-256 checksum")?; let mut command = Command::new("powershell"); command .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) .arg(&update.installer_path) .args(["-Channel", release.channel.as_str(), "-LocalPackagePath"]) .arg(&update.package_path) .args([ "-LocalPackageFormat", &release.package_format, "-LocalPackageIdentity", release.label(), "-LocalPackageSha256", expected_sha256, ]) // Drop any inherited PSModulePath. When herdr is launched from // PowerShell 7, its Core module paths come first and Windows // PowerShell 5.1 (this `powershell`) fails to autoload cmdlets like // Get-FileHash. Removing it lets 5.1 compute its own default path. // See PowerShell/PowerShell#8635. .env_remove("PSModulePath"); let status = command .status() .map_err(|err| format!("failed to run Windows installer: {err}"))?; if !status.success() { return Err(format!("Windows installer failed with status {status}")); } Ok(()) } #[cfg(windows)] fn windows_installed_herdr_exe_path() -> Result { if let Some(install_dir) = env::var_os("HERDR_INSTALL_DIR").filter(|value| !value.is_empty()) { return Ok(PathBuf::from(install_dir).join("herdr.exe")); } let local_app_data = env::var_os("LOCALAPPDATA") .ok_or("LOCALAPPDATA is not set; cannot locate Herdr install")?; Ok(PathBuf::from(local_app_data) .join("Programs") .join("Herdr") .join("bin") .join("herdr.exe")) } // --------------------------------------------------------------------------- // Upgrade flow helpers // --------------------------------------------------------------------------- fn running_inside_herdr_env(herdr_env: Option<&str>) -> bool { herdr_env == Some(crate::HERDR_ENV_VALUE) } fn running_inside_herdr() -> bool { running_inside_herdr_env(env::var(crate::HERDR_ENV_VAR).ok().as_deref()) } #[cfg(not(windows))] fn client_protocol_server_is_running_at(socket_path: &Path) -> bool { if !socket_path.exists() { return false; } crate::ipc::connect_local_stream(socket_path).is_ok() } #[cfg(not(windows))] fn client_protocol_server_is_running() -> bool { client_protocol_server_is_running_at(&crate::server::socket_paths::client_socket_path()) } #[cfg(not(windows))] fn version_label(version: Option<&str>) -> &str { version.unwrap_or("unknown") } #[cfg(not(windows))] fn update_requires_server_restart( server: &crate::api::RuntimeStatus, release: &ReleaseInfo, ) -> bool { let Some(target_generation) = release.target_endpoint_generation else { return true; }; server .capabilities .as_ref() .and_then(|capabilities| capabilities.endpoint_protocol_generation) != Some(target_generation) } #[cfg(not(windows))] fn server_supports_live_handoff(server: &crate::api::RuntimeStatus) -> bool { server .capabilities .as_ref() .is_some_and(|capabilities| capabilities.live_handoff) } #[cfg(not(windows))] fn parse_stop_old_servers_after_update_response(input: &str, default_yes: bool) -> Option { let trimmed = input.trim().to_ascii_lowercase(); match trimmed.as_str() { "" => Some(default_yes), "y" | "yes" => Some(true), "n" | "no" => Some(false), _ => None, } } #[derive(Debug, Clone, PartialEq, Eq)] #[cfg(not(windows))] struct RunningServerUpdatePlan { target: RunningUpdateTarget, server: crate::api::RuntimeStatus, requires_server_restart: bool, } #[cfg(not(windows))] impl RunningServerUpdatePlan { fn label(&self) -> &str { &self.target.label } fn socket_path(&self) -> &Path { &self.target.socket_path } fn stop_command(&self) -> String { self.target.stop_command.clone() } fn attach_command(&self) -> Option { self.target.attach_command.clone() } fn target_noun(&self) -> &'static str { if self.target.attach_command.is_some() { "session" } else { "server" } } } #[derive(Debug, Clone, PartialEq, Eq)] #[cfg(not(windows))] struct RunningServerUpdateDecision { plan: RunningServerUpdatePlan, action: RunningServerUpdateAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg(not(windows))] enum RunningServerUpdateAction { None, LiveHandoff, StopOldServer, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg(not(windows))] enum RunningServerUpdateOutcome { CompatibleServerKept, RestartDeferred, Stopped, LiveHandoffComplete, FailedHandoffOldServerKept, FailedHandoffOldServerStopped, FailedHandoffNoServer, FailedHandoffUnknown, } #[derive(Debug, Clone, PartialEq, Eq)] #[cfg(not(windows))] struct RunningSessionUpdateOutcome { session_label: String, target_noun: &'static str, stop_command: String, attach_command: Option, server_version: Option, outcome: RunningServerUpdateOutcome, } #[derive(Debug, Clone, PartialEq, Eq)] #[cfg(not(windows))] enum FailedHandoffServerState { UpdatedServerRunning, OldServerRunning(crate::api::RuntimeStatus), NoServerResponding, Unknown(String), } #[cfg(not(windows))] fn plan_running_server_updates( release: &ReleaseInfo, ) -> Result, String> { let targets = running_update_targets()?; let mut plans = Vec::new(); for target in targets { let server = match crate::api::read_runtime_status_at( &target.socket_path, SERVER_STOP_RESPONSE_TIMEOUT, ) .map_err(|err| { format!( "failed to read status for herdr target {} at {}: {err}. stop it with `{}` and run `herdr update` again", target.label, target.socket_path.display(), target.stop_command ) })? { Some(server) => server, None if target.must_be_running => { return Err(format!( "herdr target {} looked running, but its status API did not respond at {}. stop it with `{}` and run `herdr update` again", target.label, target.socket_path.display(), target.stop_command )); } None if client_protocol_server_is_running_at(&target.client_socket_path) => { return Err(format!( "herdr target {} has a client socket, but its status API did not respond at {}. stop it with `{}` and run `herdr update` again", target.label, target.socket_path.display(), target.stop_command )); } None => continue, }; plans.push(RunningServerUpdatePlan { requires_server_restart: update_requires_server_restart(&server, release), server, target, }); } if plans.is_empty() && target_client_protocol_server_is_running()? { return Err(format!( "a herdr server is listening, but its status API is unavailable; try `{}`, or stop the old server process manually, then run `herdr update` again", crate::session::local_stop_command() )); } Ok(plans) } #[derive(Debug, Clone, PartialEq, Eq)] #[cfg(not(windows))] struct RunningUpdateTarget { name: Option, label: String, stop_command: String, attach_command: Option, socket_path: PathBuf, client_socket_path: PathBuf, must_be_running: bool, } #[cfg(not(windows))] fn running_update_targets() -> Result, String> { if crate::session::explicit_session_requested() { return Ok(vec![RunningUpdateTarget { name: crate::session::active_name(), label: crate::session::active_name() .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()), stop_command: crate::session::local_stop_command(), attach_command: Some(crate::session::local_attach_command()), socket_path: crate::api::socket_path(), client_socket_path: crate::server::socket_paths::client_socket_path(), must_be_running: false, }]); } if let Some(socket_path) = std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR) { let socket_path = PathBuf::from(socket_path); return Ok(vec![RunningUpdateTarget { name: None, label: socket_path.display().to_string(), stop_command: format!( "{}={} herdr server stop", crate::api::SOCKET_PATH_ENV_VAR, socket_path.display() ), attach_command: None, client_socket_path: crate::server::socket_paths::client_socket_path_from_overrides( Some(&socket_path.to_string_lossy()), None, ), socket_path, must_be_running: false, }]); } let sessions = crate::session::list_sessions() .map_err(|err| format!("failed to list herdr sessions: {err}"))?; Ok(sessions .into_iter() .map(|session| RunningUpdateTarget { name: if session.default { None } else { Some(session.name.clone()) }, stop_command: crate::session::stop_command_for(if session.default { None } else { Some(&session.name) }), attach_command: Some(if session.default { "herdr".to_string() } else { format!("herdr session attach {}", session.name) }), label: session.name.clone(), client_socket_path: crate::session::client_socket_path_for(if session.default { None } else { Some(&session.name) }), socket_path: PathBuf::from(session.socket_path), must_be_running: session.running, }) .collect()) } #[cfg(not(windows))] fn target_client_protocol_server_is_running() -> Result { if crate::session::explicit_session_requested() || std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some() { return Ok(client_protocol_server_is_running()); } let sessions = crate::session::list_sessions() .map_err(|err| format!("failed to list herdr sessions: {err}"))?; Ok(sessions.into_iter().any(|session| { let client_socket = crate::session::client_socket_path_for(if session.default { None } else { Some(&session.name) }); client_protocol_server_is_running_at(&client_socket) })) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) struct SelfUpdateOptions { pub(crate) live_handoff: bool, } pub(crate) fn parse_self_update_args(args: &[String]) -> Result { let mut options = SelfUpdateOptions::default(); for arg in args { match arg.as_str() { "--handoff" => options.live_handoff = true, "--help" | "-h" => { return Err("usage: herdr update [--handoff]".to_string()); } _ => return Err(format!("unknown update option: {arg}")), } } Ok(options) } #[cfg(not(windows))] fn prompt_to_stop_old_servers_before_update( plans: &[RunningServerUpdatePlan], release: &ReleaseInfo, ) -> Result { if !io::stdin().is_terminal() { return Err( "one or more Herdr sessions must stop for this update. Stop running Herdr sessions when ready, then run `herdr update` again from an interactive terminal." .to_string(), ); } eprintln!( "Running sessions that must stop to use {}:", release.label() ); for plan in plans { eprintln!( " {}: server v{}", plan.label(), version_label(plan.server.version.as_deref()) ); } eprintln!(); eprintln!("If you choose no, these sessions keep using the old server until you stop them."); eprintln!("Stop the old server after installing? Stopping exits pane processes."); loop { eprint!("stop after installing? [y/N] "); io::stderr() .flush() .map_err(|e| format!("failed to flush prompt: {e}"))?; let mut input = String::new(); let read = io::stdin() .read_line(&mut input) .map_err(|e| format!("failed to read prompt response: {e}"))?; if read == 0 { return Ok(false); } match input.trim().to_ascii_lowercase().as_str() { "y" | "yes" => return Ok(true), "" | "n" | "no" => return Ok(false), _ => eprintln!("please answer y or n"), } } } #[cfg(not(windows))] fn confirm_running_server_update_action( plans: Vec, release: &ReleaseInfo, options: SelfUpdateOptions, ) -> Result, String> { if plans.is_empty() { return Ok(Vec::new()); } print_running_session_update_summary(&plans, release, options); if !options.live_handoff { return Ok(plans .into_iter() .map(|plan| RunningServerUpdateDecision { plan, action: RunningServerUpdateAction::None, }) .collect()); } let handoff_unsupported_requiring_update: Vec<&RunningServerUpdatePlan> = plans .iter() .filter(|plan| !server_supports_live_handoff(&plan.server) && plan.requires_server_restart) .collect(); let stop_unsupported = if handoff_unsupported_requiring_update.is_empty() { false } else { let owned: Vec = handoff_unsupported_requiring_update .iter() .map(|plan| (*plan).clone()) .collect(); prompt_to_stop_old_servers_before_update(&owned, release)? }; let mut decisions = Vec::new(); for plan in plans { let action = if server_supports_live_handoff(&plan.server) { RunningServerUpdateAction::LiveHandoff } else if !server_supports_live_handoff(&plan.server) && plan.requires_server_restart && stop_unsupported { RunningServerUpdateAction::StopOldServer } else { RunningServerUpdateAction::None }; decisions.push(RunningServerUpdateDecision { plan, action }); } Ok(decisions) } #[cfg(not(windows))] fn target_group_nouns(plans: &[&RunningServerUpdatePlan]) -> (&'static str, &'static str) { let all_sessions = plans.iter().all(|plan| plan.target_noun() == "session"); let all_servers = plans.iter().all(|plan| plan.target_noun() == "server"); if all_sessions { ("session", "sessions") } else if all_servers { ("server", "servers") } else { ("target", "targets") } } #[cfg(not(windows))] fn prompt_to_complete_plain_update( decisions: &[RunningServerUpdateDecision], release: &ReleaseInfo, ) -> Result { let plans: Vec<&RunningServerUpdatePlan> = decisions .iter() .filter(|decision| decision.plan.requires_server_restart) .map(|decision| &decision.plan) .collect(); if plans.is_empty() { return Ok(true); } if !io::stdin().is_terminal() { return Ok(false); } let (singular, plural) = target_group_nouns(&plans); let noun = if plans.len() == 1 { singular } else { plural }; eprintln!( "To complete the update, Herdr must stop {} running {}.", plans.len(), noun ); eprintln!("This stops active pane processes, including shells, dev servers, and tests."); for plan in plans { eprintln!( " {} {}: server v{}", plan.target_noun(), plan.label(), version_label(plan.server.version.as_deref()) ); } loop { eprint!( "Stop running {} and install {} now? [y/N] ", noun, release.label() ); io::stderr() .flush() .map_err(|e| format!("failed to flush prompt: {e}"))?; let mut input = String::new(); let read = io::stdin() .read_line(&mut input) .map_err(|e| format!("failed to read prompt response: {e}"))?; if read == 0 { return Ok(false); } if let Some(answer) = parse_stop_old_servers_after_update_response(&input, false) { return Ok(answer); } eprintln!("please answer y or n"); } } #[cfg(not(windows))] fn mark_plain_update_stop_decisions( decisions: Vec, ) -> Vec { decisions .into_iter() .map(|mut decision| { if decision.plan.requires_server_restart { decision.action = RunningServerUpdateAction::StopOldServer; } decision }) .collect() } #[cfg(not(windows))] fn print_running_session_update_summary( plans: &[RunningServerUpdatePlan], release: &ReleaseInfo, options: SelfUpdateOptions, ) { eprintln!("running herdr targets:"); for plan in plans { if options.live_handoff { let capability = if server_supports_live_handoff(&plan.server) { "handoff supported" } else { "too old for handoff" }; eprintln!( " {}: server v{} ({})", plan.label(), version_label(plan.server.version.as_deref()), capability ); } else { eprintln!( " {}: server v{}", plan.label(), version_label(plan.server.version.as_deref()) ); } } eprintln!(" update: {}", release.label()); eprintln!(); } #[cfg(not(windows))] fn live_handoff_running_server_for_update( plan: &RunningServerUpdatePlan, release: &ReleaseInfo, updated_exe: &Path, ) -> Result<(), String> { eprintln!( "asking {} {} to hand off live panes to the updated server...", plan.target_noun(), plan.label() ); live_handoff_server_via_api_for_update_at(plan.socket_path(), updated_exe, release)?; wait_for_server_handoff_at(plan.socket_path(), SERVER_HANDOFF_CONFIRM_TIMEOUT, release)?; eprintln!( "live handoff complete for {} {}; pane processes should still be running.", plan.target_noun(), plan.label() ); Ok(()) } #[cfg(not(windows))] fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &ReleaseInfo) -> bool { let protocol_matches = release .target_protocol .is_none_or(|protocol| status.protocol == Some(protocol)); let version_matches = status.version.as_deref() == Some(release.label()); protocol_matches && version_matches } #[cfg(not(windows))] fn classify_failed_live_handoff_state_at( socket_path: &Path, release: &ReleaseInfo, ) -> FailedHandoffServerState { match crate::api::read_runtime_status_at(socket_path, SERVER_STOP_RESPONSE_TIMEOUT) { Ok(Some(status)) if runtime_matches_release(&status, release) => { FailedHandoffServerState::UpdatedServerRunning } Ok(Some(status)) => FailedHandoffServerState::OldServerRunning(status), Ok(None) => FailedHandoffServerState::NoServerResponding, Err(err) => FailedHandoffServerState::Unknown(err.to_string()), } } #[cfg(not(windows))] fn prompt_to_stop_old_server_after_failed_handoff( plan: &RunningServerUpdatePlan, release: &ReleaseInfo, status: &crate::api::RuntimeStatus, ) -> Result { eprintln!( "live handoff failed, but {} {} is still running with your panes.", plan.target_noun(), plan.label() ); eprintln!(" server: v{}", version_label(status.version.as_deref())); eprintln!(" installed: {}", release.label()); eprintln!( "you can keep using the old server, or stop it now so the next `herdr` start uses {}.", release.label() ); eprintln!("stopping the old server will exit its pane processes."); if !io::stdin().is_terminal() { eprintln!( "not stopping the old server from a non-interactive update; run `{}` when you are ready.", plan.stop_command() ); return Ok(false); } loop { eprint!("stop the old server now? [y/N] "); io::stderr() .flush() .map_err(|e| format!("failed to flush prompt: {e}"))?; let mut input = String::new(); let read = io::stdin() .read_line(&mut input) .map_err(|e| format!("failed to read prompt response: {e}"))?; if read == 0 { return Ok(false); } match input.trim().to_ascii_lowercase().as_str() { "y" | "yes" => return Ok(true), "" | "n" | "no" => return Ok(false), _ => eprintln!("please answer y or n"), } } } #[cfg(not(windows))] fn recover_failed_live_handoff_for_update( plan: &RunningServerUpdatePlan, release: &ReleaseInfo, error: &str, ) -> Result { eprintln!( "live handoff failed for {} {}: {error}", plan.target_noun(), plan.label() ); match classify_failed_live_handoff_state_at(plan.socket_path(), release) { FailedHandoffServerState::UpdatedServerRunning => { eprintln!( "the updated server is running for {} {}.", plan.target_noun(), plan.label() ); Ok(RunningServerUpdateOutcome::LiveHandoffComplete) } FailedHandoffServerState::OldServerRunning(status) => { if prompt_to_stop_old_server_after_failed_handoff(plan, release, &status)? { stop_running_server_for_update(plan)?; Ok(RunningServerUpdateOutcome::FailedHandoffOldServerStopped) } else { Ok(RunningServerUpdateOutcome::FailedHandoffOldServerKept) } } FailedHandoffServerState::NoServerResponding => { if let Some(command) = plan.attach_command() { eprintln!( "no herdr server is responding for session {}. the binary was updated; run `{command}` to start {}.", plan.label(), release.label() ); } else { eprintln!( "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use {}.", plan.socket_path().display(), release.label() ); } Ok(RunningServerUpdateOutcome::FailedHandoffNoServer) } FailedHandoffServerState::Unknown(status_error) => { eprintln!( "herdr could not determine server state for {} {} after the failed handoff: {status_error}", plan.target_noun(), plan.label() ); eprintln!("{}", reconnect_or_stop_guidance(plan)); Ok(RunningServerUpdateOutcome::FailedHandoffUnknown) } } } #[cfg(not(windows))] fn reconnect_or_stop_guidance(plan: &RunningServerUpdatePlan) -> String { if let Some(command) = plan.attach_command() { format!( "if `{command}` does not reconnect cleanly, stop the old server with `{}` and run `{command}` again.", plan.stop_command() ) } else { format!( "if reconnecting with the same socket override does not work, stop the old server with `{}`.", plan.stop_command() ) } } #[cfg(not(windows))] fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { use crate::api::schema::{EmptyParams, Method}; send_server_update_method_at( socket_path, timeout, "update:server:stop", Method::ServerStop(EmptyParams::default()), "server stop", ) } #[cfg(not(windows))] fn send_server_update_method_at( socket_path: &Path, timeout: Duration, request_id: &str, method: crate::api::schema::Method, error_prefix: &str, ) -> Result<(), String> { use crate::api::schema::Request; let request = Request { id: request_id.into(), method, }; let mut stream = crate::ipc::connect_local_stream(socket_path) .map_err(|e| format!("failed to connect to running server: {e}"))?; stream .set_send_timeout(Some(timeout)) .map_err(|e| format!("failed to set {error_prefix} write timeout: {e}"))?; stream .set_recv_timeout(Some(timeout)) .map_err(|e| format!("failed to set {error_prefix} read timeout: {e}"))?; stream .write_all( serde_json::to_string(&request) .map_err(|e| e.to_string())? .as_bytes(), ) .map_err(|e| format!("failed to send {error_prefix} request: {e}"))?; stream .write_all(b"\n") .map_err(|e| format!("failed to finish {error_prefix} request: {e}"))?; stream .flush() .map_err(|e| format!("failed to flush {error_prefix} request: {e}"))?; let mut reader = BufReader::new(stream); let mut line = String::new(); let read = reader .read_line(&mut line) .map_err(|e| format!("failed to read {error_prefix} response: {e}"))?; if read == 0 || line.trim().is_empty() { return Err(format!("empty {error_prefix} response")); } let response: serde_json::Value = serde_json::from_str(&line).map_err(|e| format!("invalid server response: {e}"))?; if let Some(error) = response.get("error") { return Err(format!("{error_prefix} failed: {error}")); } Ok(()) } #[cfg(all(test, unix))] fn live_handoff_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { use crate::api::schema::{Method, ServerLiveHandoffParams}; let params = ServerLiveHandoffParams::default(); send_server_update_method_at( socket_path, timeout, "update:server:live-handoff", Method::ServerLiveHandoff(params), "server live handoff", ) } #[cfg(not(windows))] fn live_handoff_server_via_api_for_release_at( socket_path: &Path, timeout: Duration, updated_exe: &Path, release: &ReleaseInfo, ) -> Result<(), String> { use crate::api::schema::{Method, ServerLiveHandoffParams}; let params = ServerLiveHandoffParams { import_exe: Some(updated_exe.display().to_string()), expected_protocol: release.target_protocol, expected_version: Some(release.label().to_string()), }; send_server_update_method_at( socket_path, timeout, "update:server:live-handoff", Method::ServerLiveHandoff(params), "server live handoff", ) } #[cfg(not(windows))] fn live_handoff_server_via_api_for_update_at( socket_path: &Path, updated_exe: &Path, release: &ReleaseInfo, ) -> Result<(), String> { live_handoff_server_via_api_for_release_at( socket_path, SERVER_HANDOFF_REQUEST_TIMEOUT, updated_exe, release, ) } #[cfg(not(windows))] fn server_shutdown_confirmed_at(socket_path: &Path) -> Result { if !socket_path.exists() { return Ok(true); } match crate::ipc::connect_local_stream(socket_path) { Ok(_) => Ok(false), Err(err) if matches!( err.kind(), io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound | io::ErrorKind::TimedOut ) => { Ok(true) } Err(err) => Err(format!( "failed to confirm whether the old server stopped on {}: {err}", socket_path.display() )), } } #[cfg(not(windows))] fn wait_for_server_shutdown_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { let deadline = Instant::now() + timeout; loop { if server_shutdown_confirmed_at(socket_path)? { return Ok(()); } if Instant::now() >= deadline { return Err(format!( "shutdown was requested, but the old server is still responding on {} after {} seconds", socket_path.display(), timeout.as_secs() )); } std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL); } } #[cfg(not(windows))] fn stop_running_server_for_update(plan: &RunningServerUpdatePlan) -> Result<(), String> { eprintln!("stopping herdr {} {}...", plan.target_noun(), plan.label()); stop_server_via_api_at(plan.socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)?; wait_for_server_shutdown_at(plan.socket_path(), SERVER_HANDOFF_CONFIRM_TIMEOUT)?; Ok(()) } #[cfg(not(windows))] fn wait_for_server_handoff_at( socket_path: &Path, timeout: Duration, release: &ReleaseInfo, ) -> Result<(), String> { wait_for_running_server_protocol_at( socket_path, timeout, release.target_protocol, Some(release.label()), ) } #[cfg(not(windows))] fn wait_for_running_server_protocol_at( socket_path: &Path, timeout: Duration, expected_protocol: Option, expected_version: Option<&str>, ) -> Result<(), String> { let deadline = Instant::now() + timeout; loop { if let Some(status) = crate::api::read_runtime_status_at(socket_path, SERVER_STOP_RESPONSE_TIMEOUT) .map_err(|e| format!("failed to read server status after handoff: {e}"))? { let protocol_matches = expected_protocol.is_none_or(|protocol| status.protocol == Some(protocol)); let version_matches = expected_version.is_none_or(|version| status.version.as_deref() == Some(version)); if protocol_matches && version_matches { return Ok(()); } } if Instant::now() >= deadline { return Err(format!( "live handoff was requested, but no compatible server responded on {} after {} seconds", socket_path.display(), timeout.as_secs() )); } std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL); } } #[cfg(not(windows))] fn apply_running_session_update_decisions( release: &ReleaseInfo, updated_exe: &Path, decisions: Vec, ) -> Result, String> { let mut outcomes = Vec::new(); for decision in decisions { let outcome = match decision.action { RunningServerUpdateAction::None if decision.plan.requires_server_restart => { RunningServerUpdateOutcome::RestartDeferred } RunningServerUpdateAction::None => RunningServerUpdateOutcome::CompatibleServerKept, RunningServerUpdateAction::StopOldServer => { stop_running_server_for_update(&decision.plan)?; RunningServerUpdateOutcome::Stopped } RunningServerUpdateAction::LiveHandoff => { match live_handoff_running_server_for_update(&decision.plan, release, updated_exe) { Ok(()) => RunningServerUpdateOutcome::LiveHandoffComplete, Err(err) => { recover_failed_live_handoff_for_update(&decision.plan, release, &err)? } } } }; let stop_command = decision.plan.stop_command(); outcomes.push(RunningSessionUpdateOutcome { session_label: decision.plan.label().to_string(), target_noun: decision.plan.target_noun(), stop_command, attach_command: decision.plan.attach_command(), server_version: decision.plan.server.version.clone(), outcome, }); } Ok(outcomes) } #[cfg(not(windows))] fn print_running_session_update_outcomes( outcomes: &[RunningSessionUpdateOutcome], release: &ReleaseInfo, ) { if outcomes.is_empty() { eprintln!("run herdr again."); return; } let kept: Vec> = outcomes .iter() .filter(|outcome| outcome.outcome == RunningServerUpdateOutcome::CompatibleServerKept) .map(|outcome| KeptServer { label: &outcome.session_label, version: version_label(outcome.server_version.as_deref()), stop_command: &outcome.stop_command, attach_command: outcome.attach_command.as_deref(), }) .collect(); for line in kept_server_notice_lines(&kept, release.label()) { eprintln!("{line}"); } for outcome in outcomes { match outcome.outcome { RunningServerUpdateOutcome::CompatibleServerKept => {} RunningServerUpdateOutcome::LiveHandoffComplete => { if let Some(command) = &outcome.attach_command { eprintln!( "session {} was replaced; reconnect clients with `{command}`.", outcome.session_label ); } else { eprintln!( "server {} was replaced; reconnect using the same socket override.", outcome.session_label ); } } RunningServerUpdateOutcome::RestartDeferred => { eprintln!( "{} {} kept running.", outcome.target_noun, outcome.session_label ); eprintln!("Stopping exits active pane processes."); match &outcome.attach_command { Some(command) => eprintln!( "Run `{}`, then run `{command}` when ready to use {}.", outcome.stop_command, release.label() ), None => eprintln!( "Run `{}`, then restart Herdr with the same socket override when ready to use {}.", outcome.stop_command, release.label() ), } } RunningServerUpdateOutcome::Stopped | RunningServerUpdateOutcome::FailedHandoffOldServerStopped | RunningServerUpdateOutcome::FailedHandoffNoServer => { if let Some(command) = &outcome.attach_command { eprintln!( "session {} is stopped; run `{command}` again.", outcome.session_label ); } else { eprintln!( "server {} is stopped; restart it with the same socket override.", outcome.session_label ); } } RunningServerUpdateOutcome::FailedHandoffOldServerKept => { eprintln!( "{} {} is still running server v{}.", outcome.target_noun, outcome.session_label, version_label(outcome.server_version.as_deref()) ); eprintln!( "{}", crate::session::restart_after_update_guidance( &outcome.stop_command, outcome.attach_command.as_deref() ) ); } RunningServerUpdateOutcome::FailedHandoffUnknown => { if let Some(command) = &outcome.attach_command { eprintln!( "session {} state is unclear; run `{command}`, or stop the old server with `{}` if reconnect fails.", outcome.session_label, outcome.stop_command ); } else { eprintln!( "server {} state is unclear; reconnect with the same socket override, or stop it with `{}` if reconnect fails.", outcome.session_label, outcome.stop_command ); } } } } } // --------------------------------------------------------------------------- // Installation manager detection // --------------------------------------------------------------------------- pub(crate) fn update_install_command() -> &'static str { if is_homebrew_managed_install() { HOMEBREW_UPDATE_COMMAND } else if is_mise_managed_install() { MISE_UPDATE_COMMAND } else if is_nix_managed_install() { NIX_UPDATE_COMMAND } else { HERDR_UPDATE_COMMAND } } pub(crate) fn update_install_instruction(install_command: &str) -> String { match install_command { HERDR_UPDATE_COMMAND => { "detach, run `herdr update`, then run Herdr again to reconnect".to_string() } HOMEBREW_UPDATE_COMMAND => { "detach, run `brew update && brew upgrade herdr`, then run Herdr again to reconnect" .to_string() } MISE_UPDATE_COMMAND => { "detach, run `mise upgrade herdr`, then run Herdr again to reconnect".to_string() } NIX_UPDATE_COMMAND => { "detach, update through Nix, then run Herdr again to reconnect".to_string() } command => format!("detach, run `{command}`, then run Herdr again to reconnect"), } } fn is_homebrew_managed_install() -> bool { let Ok(current_exe) = env::current_exe() else { return false; }; is_homebrew_managed_exe_path_following_links(¤t_exe) } fn is_nix_managed_install() -> bool { let Ok(current_exe) = env::current_exe() else { return false; }; is_nix_store_exe_path_following_links(¤t_exe) } fn is_mise_managed_install() -> bool { let Ok(current_exe) = env::current_exe() else { return false; }; is_mise_managed_exe_path_following_links(¤t_exe) } pub(crate) fn preview_channel_rejection_for_current_install() -> Option<&'static str> { let Ok(current_exe) = env::current_exe() else { return None; }; preview_channel_rejection_for_exe_path(¤t_exe) } pub(crate) fn package_manager_channel_update_guidance_for_current_install() -> Option<&'static str> { if is_homebrew_managed_install() { Some("Use `brew update && brew upgrade herdr` to update Homebrew installs.") } else if is_mise_managed_install() { Some("Use `mise upgrade herdr` to update mise installs.") } else if is_nix_managed_install() { Some("Update through Nix to update Nix-managed Herdr installs.") } else { None } } fn preview_channel_rejection_for_exe_path(path: &Path) -> Option<&'static str> { if is_homebrew_managed_exe_path_following_links(path) { Some( "preview channel is only available for direct Herdr installs; Homebrew installs update through `brew update && brew upgrade herdr`", ) } else if is_mise_managed_exe_path_following_links(path) { Some( "preview channel is only available for direct Herdr installs; mise installs update through `mise upgrade herdr`", ) } else if is_nix_store_exe_path_following_links(path) { Some("preview channel is only available for direct Herdr installs; Nix installs update through Nix") } else { None } } #[cfg(unix)] pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool { is_homebrew_managed_exe_path_following_links(path) || is_mise_managed_exe_path_following_links(path) || is_nix_store_exe_path_following_links(path) } #[cfg(not(unix))] pub(crate) fn is_package_manager_managed_exe_path(_path: &Path) -> bool { false } fn is_homebrew_managed_exe_path_following_links(path: &Path) -> bool { if is_homebrew_managed_exe_path(path) { return true; } path.canonicalize() .is_ok_and(|path| is_homebrew_managed_exe_path(&path)) } fn is_nix_store_exe_path_following_links(path: &Path) -> bool { if is_nix_store_exe_path(path) { return true; } path.canonicalize() .is_ok_and(|path| is_nix_store_exe_path(&path)) } fn is_mise_managed_exe_path_following_links(path: &Path) -> bool { if is_mise_managed_exe_path(path) { return true; } path.canonicalize() .is_ok_and(|path| is_mise_managed_exe_path(&path)) } fn is_nix_store_exe_path(path: &Path) -> bool { path.starts_with("/nix/store") } fn is_mise_managed_exe_path(path: &Path) -> bool { mise_install_root(path).is_some() } fn mise_install_root(path: &Path) -> Option { if let Some(root) = mise_install_root_under_configured_installs_dir(path) { return Some(root); } mise_install_root_under_named_installs_dir(path) } fn mise_install_root_under_configured_installs_dir(path: &Path) -> Option { let installs_dir = env::var_os(MISE_INSTALLS_DIR_ENV) .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty())?; let version_dir = mise_tool_version_dir(path)?; let tool_dir = version_dir.parent()?; paths_match(tool_dir.parent()?, &installs_dir).then_some(version_dir.to_path_buf()) } fn mise_install_root_under_named_installs_dir(path: &Path) -> Option { let version_dir = mise_tool_version_dir(path)?; let tool_dir = version_dir.parent()?; let installs_dir = tool_dir.parent()?; if installs_dir.file_name()? != "installs" { return None; } Some(version_dir.to_path_buf()) } fn mise_tool_version_dir(path: &Path) -> Option<&Path> { if path.file_name()? != "herdr" { return None; } let bin_dir = path.parent()?; if bin_dir.file_name()? != "bin" { return None; } let version_dir = bin_dir.parent()?; let tool_dir = version_dir.parent()?; if tool_dir.file_name()? != "herdr" { return None; } Some(version_dir) } fn paths_match(left: &Path, right: &Path) -> bool { if left == right { return true; } let Ok(left) = left.canonicalize() else { return false; }; let Ok(right) = right.canonicalize() else { return false; }; left == right } fn is_homebrew_managed_exe_path(path: &Path) -> bool { homebrew_cellar_keg_root(path).is_some() } fn homebrew_cellar_keg_root(path: &Path) -> Option { if path.file_name()? != "herdr" { return None; } let bin_dir = path.parent()?; if bin_dir.file_name()? != "bin" { return None; } let version_dir = bin_dir.parent()?; let formula_dir = version_dir.parent()?; if formula_dir.file_name()? != "herdr" { return None; } let cellar_dir = formula_dir.parent()?; if cellar_dir.file_name()? != "Cellar" { return None; } Some(version_dir.to_path_buf()) } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Manual self-update command (`herdr update`). pub fn self_update(options: SelfUpdateOptions) -> Result { let channel = UpdateChannel::configured(); if is_homebrew_managed_install() { if channel == UpdateChannel::Preview { return Err( "self-update is disabled for Homebrew installs; preview is only available for direct Herdr installs".into(), ); } return Err(format!( "self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`" )); } if is_mise_managed_install() { if channel == UpdateChannel::Preview { return Err( "self-update is disabled for mise installs; preview is only available for direct Herdr installs".into(), ); } return Err(format!( "self-update is disabled for mise installs; run `{MISE_UPDATE_COMMAND}`" )); } if is_nix_managed_install() { if channel == UpdateChannel::Preview { return Err( "self-update is disabled for Nix installs; preview is only available for direct Herdr installs".into(), ); } return Err( "self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(), ); } if running_inside_herdr() { return Err("run `herdr update` outside herdr after detaching from the session".into()); } eprintln!("checking {} channel for updates...", channel.as_str()); let current = Version::current(); let release = match check_latest()? { Some(r) => r, None => { eprintln!("already up to date ({})", crate::build_info::version()); return Ok(current); } }; if let Some(commit) = &release.commit { tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build"); } if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } #[cfg(windows)] { let _ = options; eprintln!( "installing {} with the Windows installer...", release.label() ); if let Some(sha256) = &release.sha256 { tracing::debug!(sha256 = %sha256, "selected Windows update asset has checksum"); } eprintln!("downloading {}...", release.label()); let downloaded_update = download_windows_update(&release)?; eprintln!("downloaded {}", release.label()); install_windows_update_with_installer(&release, &downloaded_update)?; let updated_exe = windows_installed_herdr_exe_path()?; eprintln!("installed {}", release.label()); print_outdated_integration_notice_with_updated_binary(&updated_exe); eprintln!( "Open a new terminal, or reconnect SSH, then start Herdr again to use the updated client. Running servers remain active; restart them later only if you need server-side changes from {}.", release.label() ); print_saved_machine_update_notice(); } #[cfg(not(windows))] { let running_server_plans = plan_running_server_updates(&release)?; let server_update_decisions = confirm_running_server_update_action(running_server_plans, &release, options)?; eprintln!("downloading {}...", release.label()); let downloaded_update = download_update(&release)?; let updated_exe = downloaded_update.current_exe.clone(); eprintln!("downloaded {}", release.label()); if !options.live_handoff && !prompt_to_complete_plain_update(&server_update_decisions, &release)? { eprintln!("Herdr was not updated."); eprintln!("Stop running Herdr sessions when ready, then run `herdr update` again."); return Ok(current); } install_downloaded_update(downloaded_update)?; eprintln!("installed {}", release.label()); let server_update_decisions = if options.live_handoff { server_update_decisions } else { mark_plain_update_stop_decisions(server_update_decisions) }; let server_update_outcomes = apply_running_session_update_decisions( &release, &updated_exe, server_update_decisions, )?; print_outdated_integration_notice_with_updated_binary(&updated_exe); print_running_session_update_outcomes(&server_update_outcomes, &release); print_saved_machine_update_notice(); } Ok(release.version) } fn print_saved_machine_update_notice() { let profiles = match crate::client::endpoint::EndpointCatalog::load_profiles() { Ok(profiles) => profiles, Err(error) => { tracing::debug!(%error, "skipping saved machine update notice"); return; } }; for line in saved_machine_update_notice_lines(&profiles) { eprintln!("{line}"); } } fn saved_machine_update_notice_lines( profiles: &[crate::client::endpoint::SavedSshEndpoint], ) -> Vec { let mut targets: Vec<&str> = Vec::new(); let mut labels: Vec<&str> = Vec::new(); for profile in profiles.iter().filter(|profile| profile.enabled) { if !targets.contains(&profile.target.as_str()) { targets.push(&profile.target); labels.push(&profile.label); } } if labels.is_empty() { return Vec::new(); } let mut lines = vec![ String::new(), "your SSH machines run their own herdr and may be older:".to_string(), ]; lines.extend(labels.into_iter().map(|label| format!(" {label}"))); lines.push( "run `herdr update` on each one. it will tell you what to restart there.".to_string(), ); lines } #[cfg(not(windows))] struct KeptServer<'a> { label: &'a str, version: &'a str, stop_command: &'a str, attach_command: Option<&'a str>, } #[cfg(not(windows))] impl KeptServer<'_> { fn reconnect(&self) -> String { match self.attach_command { Some(attach) => format!("`{attach}`"), None => "herdr with the same socket override".to_string(), } } } #[cfg(not(windows))] fn kept_server_notice_lines(kept: &[KeptServer<'_>], release_label: &str) -> Vec { match kept { [] => Vec::new(), [server] => vec![ String::new(), format!( "your running server is still v{}. run {} to reconnect; everything keeps working.", server.version, server.reconnect() ), format!("server fixes in v{release_label} apply only after it restarts."), format!( "when your agents are idle, run `{}`, then {}.", server.stop_command, server.reconnect() ), "this closes running panes and their agents.".to_string(), ], servers => { let width = servers .iter() .map(|server| server.label.chars().count()) .max() .unwrap_or(0); let mut lines = vec![ String::new(), "your running servers are still older. reconnect as usual; everything keeps working." .to_string(), format!("server fixes in v{release_label} apply only after each one restarts:"), ]; lines.extend(servers.iter().map(|server| { format!( " {:) { crate::logging::update_check_started(); if let Ok(version) = env::var(FAKE_UPDATE_VERSION_ENV) { let version = version.trim(); if !version.is_empty() { tracing::info!( env = FAKE_UPDATE_VERSION_ENV, version, "using fake update version for local testing" ); if let Err(e) = crate::release_notes::save_pending(version, &fake_release_notes_body(version)) { tracing::warn!("failed to save fake pending release notes: {e}"); } let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: version.to_string(), install_command: update_install_command().to_string(), }); } return; } let configured_channel = UpdateChannel::configured(); if is_homebrew_managed_install() { if configured_channel == UpdateChannel::Preview { crate::logging::update_check_failed( "preview channel is not available for Homebrew installs", ); return; } auto_update_homebrew(events); return; } if is_mise_managed_install() && configured_channel == UpdateChannel::Preview { crate::logging::update_check_failed("preview channel is not available for mise installs"); return; } let nix_managed_install = is_nix_managed_install(); if nix_managed_install && configured_channel == UpdateChannel::Preview { crate::logging::update_check_failed("preview channel is not available for Nix installs"); return; } let release = match check_latest() { Ok(Some(r)) => r, Ok(None) => return, Err(err) => { crate::logging::update_check_failed(&err); return; } }; crate::logging::update_available(release.label()); tracing::info!( "new {} build available at {}", release.channel.as_str(), release.download_url ); if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } tracing::info!( "auto-update check: {} available, waiting for explicit install", release.label() ); // Notify the TUI — blocking_send is safe from a std::thread let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: release.label().to_string(), install_command: update_install_command().to_string(), }); } fn auto_update_homebrew(events: tokio::sync::mpsc::Sender) { let version = match check_homebrew_latest() { Ok(Some(version)) => version, Ok(None) => return, Err(err) => { crate::logging::update_check_failed(&err); return; } }; crate::logging::update_available(&version.to_string()); let notes_body = homebrew_release_notes_body(&version); if let Err(e) = crate::release_notes::save_pending(&version.to_string(), ¬es_body) { tracing::warn!("failed to save pending release notes: {e}"); } tracing::info!( "auto-update check: v{} available through Homebrew, waiting for explicit install", version ); let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: version.to_string(), install_command: HOMEBREW_UPDATE_COMMAND.to_string(), }); } fn homebrew_release_notes_body(version: &Version) -> String { let manifest = fetch_update_manifest().ok(); homebrew_release_notes_body_from_manifest(version, manifest.as_ref()) } fn homebrew_release_notes_body_from_manifest( version: &Version, manifest: Option<&UpdateManifest>, ) -> String { if let Some(metadata) = manifest.and_then(|manifest| manifest.metadata_for_version(version)) { let notes_body = metadata.notes_body(); if !notes_body.is_empty() { handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref()); return notes_body; } } format!("### Changed\n- v{version} is available through Homebrew.") } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn platform_target() -> (&'static str, &'static str) { let os = if cfg!(target_os = "linux") { "linux" } else if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "windows") { "windows" } else { "unknown" }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { "unknown" }; (os, arch) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::net::UnixListener; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; use std::sync::{Mutex, OnceLock}; use std::thread; fn saved_machine( label: &str, target: &str, enabled: bool, ) -> crate::client::endpoint::SavedSshEndpoint { let mut profile = crate::client::endpoint::SavedSshEndpoint::new(label, target, "default").unwrap(); profile.enabled = enabled; profile } #[test] fn saved_machine_notice_lists_enabled_machines_once_per_target() { let profiles = [ saved_machine("rohan", "rohan", true), saved_machine("rohan/agents", "rohan", true), saved_machine("off", "offbox", false), saved_machine("workbox", "dev@workbox", true), ]; assert_eq!( saved_machine_update_notice_lines(&profiles), [ "", "your SSH machines run their own herdr and may be older:", " rohan", " workbox", "run `herdr update` on each one. it will tell you what to restart there.", ] ); } #[test] fn saved_machine_notice_is_empty_without_enabled_machines() { assert!(saved_machine_update_notice_lines(&[]).is_empty()); assert!( saved_machine_update_notice_lines(&[saved_machine("off", "offbox", false)]).is_empty() ); } #[test] fn kept_server_notice_names_the_session_restart_commands() { let default = KeptServer { label: "default", version: "0.9.1", stop_command: "herdr server stop", attach_command: Some("herdr"), }; assert_eq!( kept_server_notice_lines(&[default], "0.10.0"), [ "", "your running server is still v0.9.1. run `herdr` to reconnect; everything keeps working.", "server fixes in v0.10.0 apply only after it restarts.", "when your agents are idle, run `herdr server stop`, then `herdr`.", "this closes running panes and their agents.", ] ); let work = KeptServer { label: "work", version: "0.9.0", stop_command: "herdr session stop work", attach_command: Some("herdr session attach work"), }; let default = KeptServer { label: "default", version: "0.9.1", stop_command: "herdr server stop", attach_command: Some("herdr"), }; assert_eq!( kept_server_notice_lines(&[default, work], "0.10.0"), [ "", "your running servers are still older. reconnect as usual; everything keeps working.", "server fixes in v0.10.0 apply only after each one restarts:", " default v0.9.1 `herdr server stop`, then `herdr`", " work v0.9.0 `herdr session stop work`, then `herdr session attach work`", "restart one when its agents are idle. this closes its panes and their agents.", ] ); assert!(kept_server_notice_lines(&[], "0.10.0").is_empty()); let socket_override = KeptServer { label: "/tmp/herdr.sock", version: "0.9.1", stop_command: "herdr server stop", attach_command: None, }; let lines = kept_server_notice_lines(&[socket_override], "0.10.0"); assert_eq!( lines[3], "when your agents are idle, run `herdr server stop`, then herdr with the same socket override." ); } fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn unique_test_socket_path(name: &str) -> std::path::PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); std::path::PathBuf::from(format!( "/tmp/hu-{name}-{}-{nanos}.sock", std::process::id() )) } fn spawn_accept_loop(path: &Path) -> (Arc, thread::JoinHandle<()>) { let listener = UnixListener::bind(path).unwrap(); listener.set_nonblocking(true).unwrap(); let running = Arc::new(AtomicBool::new(true)); let running_thread = Arc::clone(&running); let handle = thread::spawn(move || { while running_thread.load(Ordering::Relaxed) { match listener.accept() { Ok((_stream, _)) => {} Err(err) if err.kind() == io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); } Err(_) => break, } } }); (running, handle) } fn spawn_status_server_once( path: &Path, version: &str, protocol: u32, ) -> thread::JoinHandle<()> { let listener = UnixListener::bind(path).unwrap(); let version = version.to_string(); thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = String::new(); BufReader::new(stream.try_clone().unwrap()) .read_line(&mut request) .unwrap(); assert!(request.contains("\"method\":\"ping\"")); let response = format!( r#"{{"id":"runtime:status","result":{{"type":"pong","version":"{version}","protocol":{protocol},"capabilities":{{"live_handoff":true}}}}}}"# ); stream.write_all(response.as_bytes()).unwrap(); stream.write_all(b"\n").unwrap(); stream.flush().unwrap(); }) } fn fake_release(version: &str, target_protocol: Option) -> ReleaseInfo { ReleaseInfo { version: Version::parse(version).unwrap(), identity: version.to_string(), channel: UpdateChannel::Stable, build_id: None, commit: None, target_protocol, target_endpoint_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), download_url: "https://example.com/herdr".to_string(), sha256: None, notes_body: "### Changed\n- One".to_string(), } } fn set_test_config_home(name: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let short_name: String = name.chars().take(4).collect(); let dir = PathBuf::from(format!( "/tmp/hu-{short_name}-{}-{nanos}", std::process::id() )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); std::env::set_var("XDG_CONFIG_HOME", &dir); dir } #[test] fn parse_version_basic() { assert_eq!( Version::parse("1.2.3"), Some(Version { major: 1, minor: 2, patch: 3 }) ); } #[test] fn parse_version_with_v_prefix() { assert_eq!( Version::parse("v0.1.0"), Some(Version { major: 0, minor: 1, patch: 0 }) ); } #[test] fn parse_version_invalid() { assert_eq!(Version::parse("1.2"), None); assert_eq!(Version::parse("abc"), None); assert_eq!(Version::parse(""), None); } #[test] fn homebrew_cellar_path_is_detected() { let path = Path::new("/opt/homebrew/Cellar/herdr/0.5.9/bin/herdr"); assert!(is_homebrew_managed_exe_path(path)); assert_eq!( homebrew_cellar_keg_root(path).unwrap(), PathBuf::from("/opt/homebrew/Cellar/herdr/0.5.9") ); } #[test] fn homebrew_linux_cellar_path_is_detected() { let path = Path::new("/home/linuxbrew/.linuxbrew/Cellar/herdr/0.5.9/bin/herdr"); assert!(is_homebrew_managed_exe_path(path)); } #[test] fn homebrew_opt_path_requires_canonicalized_cellar_target() { let path = Path::new("/opt/homebrew/opt/herdr/bin/herdr"); assert!(!is_homebrew_managed_exe_path(path)); } #[test] fn non_homebrew_path_is_not_detected() { let path = Path::new("/usr/local/bin/herdr"); assert!(!is_homebrew_managed_exe_path(path)); } #[test] fn mise_install_path_is_detected() { let path = Path::new("/home/user/.local/share/mise/installs/herdr/0.6.6/bin/herdr"); assert!(is_mise_managed_exe_path(path)); assert_eq!( mise_install_root(path).unwrap(), PathBuf::from("/home/user/.local/share/mise/installs/herdr/0.6.6") ); } #[test] fn mise_alias_install_path_is_detected() { let path = Path::new("/home/user/.local/share/mise/installs/herdr/latest/bin/herdr"); assert!(is_mise_managed_exe_path(path)); } #[test] fn mise_custom_installs_dir_path_is_detected() { let path = Path::new("/opt/mise-tools/installs/herdr/0.6.6/bin/herdr"); assert!(is_mise_managed_exe_path(path)); } #[test] fn mise_configured_installs_dir_path_is_detected() { let _guard = env_lock().lock().unwrap(); let previous = std::env::var_os(MISE_INSTALLS_DIR_ENV); std::env::set_var(MISE_INSTALLS_DIR_ENV, "/opt/mise-tools"); let path = Path::new("/opt/mise-tools/herdr/0.6.6/bin/herdr"); assert!(is_mise_managed_exe_path(path)); assert_eq!( mise_install_root(path).unwrap(), PathBuf::from("/opt/mise-tools/herdr/0.6.6") ); if let Some(previous) = previous { std::env::set_var(MISE_INSTALLS_DIR_ENV, previous); } else { std::env::remove_var(MISE_INSTALLS_DIR_ENV); } } #[test] fn non_mise_install_path_is_not_detected() { let path = Path::new("/home/user/.local/bin/herdr"); assert!(!is_mise_managed_exe_path(path)); } #[test] fn package_manager_path_detection_follows_homebrew_symlink() { #[cfg(unix)] { let root = std::env::temp_dir().join(format!( "herdr-homebrew-symlink-test-{}", std::process::id() )); let cellar_bin = root.join("Cellar/herdr/0.6.2/bin"); let opt_bin = root.join("opt/herdr/bin"); fs::create_dir_all(&cellar_bin).unwrap(); fs::create_dir_all(&opt_bin).unwrap(); let cellar_binary = cellar_bin.join("herdr"); let opt_binary = opt_bin.join("herdr"); fs::write(&cellar_binary, b"").unwrap(); std::os::unix::fs::symlink(&cellar_binary, &opt_binary).unwrap(); assert!(is_package_manager_managed_exe_path(&opt_binary)); let _ = fs::remove_dir_all(root); } } #[test] fn package_manager_path_detection_follows_mise_symlink() { #[cfg(unix)] { let root = std::env::temp_dir() .join(format!("herdr-mise-symlink-test-{}", std::process::id())); let version_bin = root.join("installs/herdr/0.6.2/bin"); let latest_bin = root.join("installs/herdr/latest/bin"); fs::create_dir_all(&version_bin).unwrap(); fs::create_dir_all(&latest_bin).unwrap(); let version_binary = version_bin.join("herdr"); let latest_binary = latest_bin.join("herdr"); fs::write(&version_binary, b"").unwrap(); std::os::unix::fs::symlink(&version_binary, &latest_binary).unwrap(); assert!(is_package_manager_managed_exe_path(&latest_binary)); let _ = fs::remove_dir_all(root); } } #[test] fn nix_store_path_is_detected() { let path = Path::new("/nix/store/abc123-herdr-0.6.1/bin/herdr"); assert!(is_nix_store_exe_path(path)); assert!(is_package_manager_managed_exe_path(path)); } #[test] fn preview_channel_is_rejected_for_package_manager_paths() { let homebrew = Path::new("/opt/homebrew/Cellar/herdr/0.6.6/bin/herdr"); let mise = Path::new("/home/user/.local/share/mise/installs/herdr/0.6.6/bin/herdr"); let nix = Path::new("/nix/store/abc123-herdr-0.6.6/bin/herdr"); let direct = Path::new("/home/user/.local/bin/herdr"); assert!(preview_channel_rejection_for_exe_path(homebrew) .is_some_and(|message| message.contains("Homebrew"))); assert!(preview_channel_rejection_for_exe_path(mise) .is_some_and(|message| message.contains("mise"))); assert!(preview_channel_rejection_for_exe_path(nix) .is_some_and(|message| message.contains("Nix"))); assert!(preview_channel_rejection_for_exe_path(direct).is_none()); } #[test] fn non_nix_store_path_is_not_detected() { let path = Path::new("/usr/local/bin/herdr"); assert!(!is_nix_store_exe_path(path)); } #[test] fn parse_homebrew_formula_stable_version_reads_versions_stable() { let version = parse_homebrew_formula_stable_version( br#"{"versions":{"stable":"0.5.10","head":"HEAD","bottle":true}}"#, ) .unwrap(); assert_eq!(version, Version::parse("0.5.10").unwrap()); } #[test] fn homebrew_formula_update_uses_formula_stable_not_manifest_latest() { let current = Version::parse("0.6.1").unwrap(); let update = homebrew_update_from_formula_json( br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#, ¤t, ) .unwrap(); assert_eq!(update, Some(Version::parse("0.6.2").unwrap())); } #[test] fn homebrew_formula_update_ignores_versions_that_are_not_newer() { let current = Version::parse("0.6.2").unwrap(); let update = homebrew_update_from_formula_json( br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#, ¤t, ) .unwrap(); assert_eq!(update, None); } #[test] fn homebrew_release_notes_use_package_manager_guidance() { let body = homebrew_release_notes_body_from_manifest(&Version::parse("0.6.3").unwrap(), None); assert_eq!(body, "### Changed\n- v0.6.3 is available through Homebrew."); } #[test] fn homebrew_release_notes_can_use_manifest_metadata() { let manifest: UpdateManifest = serde_json::from_str( r####"{ "version": "0.6.3", "protocol": 10, "notes": "### Fixed\n- Brew notes", "assets": { "linux-x86_64": "https://example.com/herdr-linux-x86_64" } }"####, ) .unwrap(); let body = homebrew_release_notes_body_from_manifest( &Version::parse("0.6.3").unwrap(), Some(&manifest), ); assert_eq!(body, "### Fixed\n- Brew notes"); } #[test] fn update_install_instruction_distinguishes_install_from_restart() { assert_eq!( update_install_instruction(HERDR_UPDATE_COMMAND), "detach, run `herdr update`, then run Herdr again to reconnect" ); assert_eq!( update_install_instruction(HOMEBREW_UPDATE_COMMAND), "detach, run `brew update && brew upgrade herdr`, then run Herdr again to reconnect" ); assert_eq!( update_install_instruction(MISE_UPDATE_COMMAND), "detach, run `mise upgrade herdr`, then run Herdr again to reconnect" ); } #[test] fn fake_release_notes_default_to_real_large_changelog_section() { let _guard = env_lock().lock().unwrap(); std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); let body = fake_release_notes_body("9.4.9"); assert!(body.contains("### Major Changes")); assert!(body.contains("Added tabs within workspaces")); } #[test] fn fake_release_notes_fallback_include_version_and_context() { let _guard = env_lock().lock().unwrap(); std::env::set_var(FAKE_UPDATE_NOTES_VERSION_ENV, "does-not-exist"); let body = fake_release_notes_body("9.4.9"); assert!(body.contains("v9.4.9")); assert!(body.contains("local UI validation")); assert!(body.contains(FAKE_UPDATE_VERSION_ENV)); std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); } #[test] fn running_inside_herdr_env_requires_marker() { assert!(running_inside_herdr_env(Some(crate::HERDR_ENV_VALUE))); assert!(!running_inside_herdr_env(None)); assert!(!running_inside_herdr_env(Some("0"))); } #[test] fn self_update_args_gate_live_handoff() { assert_eq!( parse_self_update_args(&[]).unwrap(), SelfUpdateOptions { live_handoff: false } ); assert_eq!( parse_self_update_args(&["--handoff".to_string()]).unwrap(), SelfUpdateOptions { live_handoff: true } ); assert_eq!( parse_self_update_args(&["--unknown".to_string()]).unwrap_err(), "unknown update option: --unknown" ); } #[test] fn parse_stop_old_servers_after_update_response_uses_prompt_default_for_blank() { assert_eq!( parse_stop_old_servers_after_update_response("", true), Some(true) ); assert_eq!( parse_stop_old_servers_after_update_response("\n", false), Some(false) ); assert_eq!( parse_stop_old_servers_after_update_response("y", false), Some(true) ); assert_eq!( parse_stop_old_servers_after_update_response("no", true), Some(false) ); assert_eq!( parse_stop_old_servers_after_update_response("later", true), None ); } #[test] fn update_requires_server_restart_only_without_the_endpoint_baseline() { let release = fake_release("0.5.6", Some(4)); let compatible = crate::api::RuntimeStatus { version: Some("0.5.5".to_string()), protocol: Some(2), capabilities: Some(crate::api::schema::ServerCapabilities { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), surface_interest: true, health_check: true, ssh_agent_registration: false, }), }; let missing_baseline = crate::api::RuntimeStatus { capabilities: None, ..compatible.clone() }; let incompatible_generation = crate::api::RuntimeStatus { capabilities: Some(crate::api::schema::ServerCapabilities { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + 1, ), ..compatible.capabilities.clone().unwrap() }), ..compatible.clone() }; assert!(!update_requires_server_restart(&compatible, &release)); assert!(update_requires_server_restart(&missing_baseline, &release)); assert!(update_requires_server_restart( &incompatible_generation, &release )); let unknown_release = ReleaseInfo { target_endpoint_generation: None, ..release.clone() }; assert!(update_requires_server_restart( &compatible, &unknown_release )); assert!(update_requires_server_restart( &missing_baseline, &unknown_release )); let future_release = ReleaseInfo { target_endpoint_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + 1, ), ..release }; assert!(update_requires_server_restart(&compatible, &future_release)); } #[test] fn plain_update_defers_stop_prompt_until_after_install() { assert!( !io::stdin().is_terminal(), "this test relies on noninteractive test stdin" ); let release = fake_release("9.8.7", Some(77)); let plan = RunningServerUpdatePlan { target: RunningUpdateTarget { name: Some("work".to_string()), label: "work".to_string(), stop_command: "herdr session stop work".to_string(), attach_command: Some("herdr session attach work".to_string()), socket_path: crate::session::api_socket_path_for(Some("work")), client_socket_path: crate::session::client_socket_path_for(Some("work")), must_be_running: true, }, requires_server_restart: true, server: crate::api::RuntimeStatus { version: Some("0.6.2".to_string()), protocol: Some(76), capabilities: Some(crate::api::schema::ServerCapabilities { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), surface_interest: true, health_check: true, ssh_agent_registration: false, }), }, }; let decisions = confirm_running_server_update_action( vec![plan], &release, SelfUpdateOptions { live_handoff: false, }, ) .unwrap(); assert_eq!(decisions.len(), 1); assert_eq!(decisions[0].action, RunningServerUpdateAction::None); assert!(decisions[0].plan.requires_server_restart); } #[test] fn plain_update_targets_all_running_sessions() { let _guard = env_lock().lock().unwrap(); let config_home = set_test_config_home("all-sessions"); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); let default_socket = crate::session::api_socket_path_for(None); let work_socket = crate::session::api_socket_path_for(Some("work")); fs::create_dir_all(default_socket.parent().unwrap()).unwrap(); fs::create_dir_all(work_socket.parent().unwrap()).unwrap(); let default_listener = UnixListener::bind(&default_socket).unwrap(); let work_listener = UnixListener::bind(&work_socket).unwrap(); let mut targets = running_update_targets().unwrap(); targets.sort_by(|left, right| left.label.cmp(&right.label)); drop(default_listener); drop(work_listener); let _ = fs::remove_dir_all(config_home); std::env::remove_var("XDG_CONFIG_HOME"); assert_eq!(targets.len(), 2); assert_eq!(targets[0].label, crate::session::DEFAULT_SESSION_NAME); assert_eq!(targets[0].name, None); assert_eq!(targets[1].label, "work"); assert_eq!(targets[1].name.as_deref(), Some("work")); } #[test] fn explicit_session_update_targets_only_that_session() { let _guard = env_lock().lock().unwrap(); let config_home = set_test_config_home("explicit-session"); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/ignored-herdr.sock"); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); let args = vec![ "herdr".to_string(), "--session".to_string(), "work".to_string(), "update".to_string(), ]; let _ = crate::session::configure_from_args(&args).unwrap(); let targets = running_update_targets().unwrap(); let expected_socket = crate::session::api_socket_path_for(Some("work")); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); std::env::remove_var("XDG_CONFIG_HOME"); crate::session::clear_explicit_session_for_test(); let _ = fs::remove_dir_all(config_home); assert_eq!(targets.len(), 1); assert_eq!(targets[0].label, "work"); assert_eq!(targets[0].name.as_deref(), Some("work")); assert_eq!(targets[0].socket_path, expected_socket); } #[test] fn socket_override_update_targets_socket_not_env_session() { let _guard = env_lock().lock().unwrap(); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/custom-herdr.sock"); std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); crate::session::clear_explicit_session_for_test(); let targets = running_update_targets().unwrap(); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); assert_eq!(targets.len(), 1); assert_eq!(targets[0].name, None); assert_eq!( targets[0].socket_path, PathBuf::from("/tmp/custom-herdr.sock") ); assert!(targets[0] .stop_command .contains(crate::api::SOCKET_PATH_ENV_VAR)); } #[test] fn plain_update_errors_when_named_session_has_client_socket_without_status_api() { let _guard = env_lock().lock().unwrap(); let config_home = set_test_config_home("client-only-session"); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); let work_client_socket = crate::session::client_socket_path_for(Some("work")); fs::create_dir_all(work_client_socket.parent().unwrap()).unwrap(); let work_client_listener = UnixListener::bind(&work_client_socket).unwrap(); let err = plan_running_server_updates(&fake_release("9.8.7", Some(77))).unwrap_err(); drop(work_client_listener); let _ = fs::remove_dir_all(config_home); std::env::remove_var("XDG_CONFIG_HOME"); assert!( err.contains("work") && err.contains("status API did not respond"), "unexpected error: {err}" ); assert!( err.contains("herdr session stop work"), "unexpected error: {err}" ); } #[test] fn failed_handoff_classification_detects_updated_server() { let socket_path = unique_test_socket_path("handoff-updated-status"); let handle = spawn_status_server_once(&socket_path, "9.8.7", 77); let release = fake_release("9.8.7", Some(77)); let state = classify_failed_live_handoff_state_at(&socket_path, &release); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert_eq!(state, FailedHandoffServerState::UpdatedServerRunning); } #[test] fn failed_handoff_classification_detects_old_server() { let socket_path = unique_test_socket_path("handoff-old-status"); let handle = spawn_status_server_once(&socket_path, "0.6.2", 76); let release = fake_release("9.8.7", Some(77)); let state = classify_failed_live_handoff_state_at(&socket_path, &release); let _ = handle.join(); let _ = fs::remove_file(&socket_path); match state { FailedHandoffServerState::OldServerRunning(status) => { assert_eq!(status.version.as_deref(), Some("0.6.2")); assert_eq!(status.protocol, Some(76)); } other => panic!("unexpected state: {other:?}"), } } #[test] fn failed_handoff_classification_detects_missing_server() { let socket_path = unique_test_socket_path("handoff-missing-status"); let release = fake_release("9.8.7", Some(77)); let state = classify_failed_live_handoff_state_at(&socket_path, &release); assert_eq!(state, FailedHandoffServerState::NoServerResponding); } #[test] fn noninteractive_plain_update_does_not_complete_with_running_server() { let _guard = env_lock().lock().unwrap(); assert!( !io::stdin().is_terminal(), "this test relies on noninteractive test stdin" ); std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); crate::session::clear_explicit_session_for_test(); let server = crate::api::RuntimeStatus { version: Some("0.5.5".to_string()), protocol: Some(2), capabilities: None, }; let release = ReleaseInfo { version: Version::parse("0.5.6").unwrap(), identity: "0.5.6".to_string(), channel: UpdateChannel::Stable, build_id: None, commit: None, target_protocol: Some(3), target_endpoint_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), download_url: "https://example.com/herdr".to_string(), sha256: None, notes_body: "### Changed\n- One".to_string(), }; let plan = RunningServerUpdatePlan { target: RunningUpdateTarget { name: Some("work".to_string()), label: "work".to_string(), stop_command: "herdr session stop work".to_string(), attach_command: Some("herdr session attach work".to_string()), socket_path: crate::session::api_socket_path_for(Some("work")), client_socket_path: crate::session::client_socket_path_for(Some("work")), must_be_running: true, }, requires_server_restart: true, server, }; let decisions = confirm_running_server_update_action( vec![plan], &release, SelfUpdateOptions { live_handoff: false, }, ) .unwrap(); let complete = prompt_to_complete_plain_update(&decisions, &release).unwrap(); assert!(!complete); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); } #[test] fn noninteractive_plain_update_completes_with_compatible_server() { assert!( !io::stdin().is_terminal(), "this test relies on noninteractive test stdin" ); let release = fake_release("9.8.7", Some(77)); let plan = RunningServerUpdatePlan { target: RunningUpdateTarget { name: Some("work".to_string()), label: "work".to_string(), stop_command: "herdr session stop work".to_string(), attach_command: Some("herdr session attach work".to_string()), socket_path: crate::session::api_socket_path_for(Some("work")), client_socket_path: crate::session::client_socket_path_for(Some("work")), must_be_running: true, }, requires_server_restart: false, server: crate::api::RuntimeStatus { version: Some("9.8.6".to_string()), protocol: Some(76), capabilities: Some(crate::api::schema::ServerCapabilities { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), surface_interest: true, health_check: true, ssh_agent_registration: false, }), }, }; let decisions = confirm_running_server_update_action( vec![plan], &release, SelfUpdateOptions { live_handoff: false, }, ) .unwrap(); assert!(prompt_to_complete_plain_update(&decisions, &release).unwrap()); let decisions = mark_plain_update_stop_decisions(decisions); assert_eq!(decisions[0].action, RunningServerUpdateAction::None); } #[test] fn client_protocol_server_is_running_at_detects_live_socket() { let socket_path = unique_test_socket_path("client-live"); let listener = UnixListener::bind(&socket_path).unwrap(); assert!(client_protocol_server_is_running_at(&socket_path)); drop(listener); let _ = fs::remove_file(&socket_path); } #[test] fn client_protocol_server_is_running_at_ignores_missing_socket() { let socket_path = unique_test_socket_path("client-missing"); assert!(!client_protocol_server_is_running_at(&socket_path)); } #[test] fn stop_server_via_api_accepts_success_response() { let socket_path = unique_test_socket_path("stop-ok"); let listener = UnixListener::bind(&socket_path).unwrap(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = String::new(); BufReader::new(stream.try_clone().unwrap()) .read_line(&mut request) .unwrap(); assert!( request.contains("server.stop") || request.contains("ServerStop") || request.contains("server_stop") || request.contains("ServerStop") ); stream .write_all(b"{\"id\":\"update:server:stop\",\"result\":{}}\n") .unwrap(); stream.flush().unwrap(); }); let result = stop_server_via_api_at(&socket_path, Duration::from_millis(200)); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( result.is_ok(), "expected stop request to succeed: {result:?}" ); } #[test] fn live_handoff_server_via_api_sends_handoff_request() { let socket_path = unique_test_socket_path("handoff-ok"); let listener = UnixListener::bind(&socket_path).unwrap(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = String::new(); BufReader::new(stream.try_clone().unwrap()) .read_line(&mut request) .unwrap(); assert!(request.contains("server.live_handoff")); stream .write_all(b"{\"id\":\"update:server:live-handoff\",\"result\":{}}\n") .unwrap(); stream.flush().unwrap(); }); let result = live_handoff_server_via_api_at(&socket_path, Duration::from_millis(200)); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( result.is_ok(), "expected handoff request to succeed: {result:?}" ); } #[test] fn update_live_handoff_request_names_import_binary_and_expected_release() { let socket_path = unique_test_socket_path("handoff-update-ok"); let listener = UnixListener::bind(&socket_path).unwrap(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = String::new(); BufReader::new(stream.try_clone().unwrap()) .read_line(&mut request) .unwrap(); let value: serde_json::Value = serde_json::from_str(&request).unwrap(); assert_eq!(value["method"], "server.live_handoff"); assert_eq!(value["params"]["import_exe"], "/tmp/herdr-new"); assert_eq!(value["params"]["expected_protocol"], 77); assert_eq!(value["params"]["expected_version"], "9.8.7"); stream .write_all(b"{\"id\":\"update:server:live-handoff\",\"result\":{}}\n") .unwrap(); stream.flush().unwrap(); }); let release = ReleaseInfo { version: Version::parse("9.8.7").unwrap(), identity: "9.8.7".to_string(), channel: UpdateChannel::Stable, build_id: None, commit: None, target_protocol: Some(77), target_endpoint_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), download_url: "https://example.com/herdr".to_string(), sha256: None, notes_body: "### Changed\n- One".to_string(), }; let result = live_handoff_server_via_api_for_release_at( &socket_path, Duration::from_millis(200), Path::new("/tmp/herdr-new"), &release, ); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( result.is_ok(), "expected handoff request to succeed: {result:?}" ); } #[test] fn stop_server_via_api_times_out_when_server_never_replies() { let socket_path = unique_test_socket_path("stop-timeout"); let listener = UnixListener::bind(&socket_path).unwrap(); let handle = thread::spawn(move || { let (_stream, _) = listener.accept().unwrap(); thread::sleep(Duration::from_millis(200)); }); let err = stop_server_via_api_at(&socket_path, Duration::from_millis(50)).unwrap_err(); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( err.contains("failed to read server stop response") || err.contains("empty server stop response"), "unexpected error: {err}" ); } #[test] fn wait_for_server_shutdown_succeeds_once_socket_stops_accepting() { let socket_path = unique_test_socket_path("shutdown-ok"); let (running, handle) = spawn_accept_loop(&socket_path); let running_for_stop = Arc::clone(&running); let stopper = thread::spawn(move || { thread::sleep(Duration::from_millis(60)); running_for_stop.store(false, Ordering::Relaxed); }); let result = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(500)); running.store(false, Ordering::Relaxed); let _ = stopper.join(); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!(result.is_ok(), "expected shutdown confirmation: {result:?}"); } #[test] fn wait_for_server_shutdown_times_out_while_socket_keeps_responding() { let socket_path = unique_test_socket_path("shutdown-timeout"); let (running, handle) = spawn_accept_loop(&socket_path); let err = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(120)).unwrap_err(); running.store(false, Ordering::Relaxed); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!(err.contains("still responding"), "unexpected error: {err}"); } #[test] fn version_ordering() { let v010 = Version::parse("0.1.0").unwrap(); let v011 = Version::parse("0.1.1").unwrap(); let v020 = Version::parse("0.2.0").unwrap(); let v100 = Version::parse("1.0.0").unwrap(); assert!(v010 < v011); assert!(v011 < v020); assert!(v020 < v100); assert!(v010 == Version::parse("0.1.0").unwrap()); } #[test] fn version_display() { let v = Version { major: 0, minor: 1, patch: 0, }; assert_eq!(v.to_string(), "0.1.0"); } #[test] fn current_version_parses() { let v = Version::current(); assert!(v.major < 100); } #[test] fn platform_target_is_known() { let (os, arch) = platform_target(); assert!(os == "linux" || os == "macos", "os: {os}"); assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}"); } #[test] fn update_manifest_deserializes() { let json = "{\n\ \"version\": \"0.2.0\",\n\ \"protocol\": 4,\n\ \"endpoint_generation\": 1,\n\ \"notes\": \"### Changed\\n- One\",\n\ \"announcement\": {\n\ \"id\": \"keymap-v2\",\n\ \"title\": \"Keymap changes\",\n\ \"body\": \"### Heads up\\n- Defaults changed\"\n\ },\n\ \"assets\": {\n\ \"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\ \"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\ }\n\ }"; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert_eq!(manifest.version, "0.2.0"); assert_eq!(manifest.protocol, Some(4)); assert_eq!(manifest.endpoint_generation, Some(1)); assert_eq!(manifest.assets.len(), 2); assert_eq!( manifest .metadata_for_version(&Version::parse("0.2.0").unwrap()) .expect("metadata") .notes_body(), "### Changed\n- One" ); assert_eq!( manifest .announcement .as_ref() .and_then(|announcement| announcement.get("id")) .and_then(serde_json::Value::as_str), Some("keymap-v2") ); assert_eq!( manifest.download_url_for("linux", "x86_64").as_deref(), Some("https://example.com/herdr-linux-x86_64") ); } #[test] fn update_manifest_reads_archived_release_metadata() { let json = r####"{ "version": "0.3.0", "protocol": 4, "notes": "### Changed\n- Three", "assets": { "linux_x86_64": "https://example.com/unused" }, "releases": { "0.2.0": { "notes": "### Changed\n- Two", "announcement": { "id": "two", "title": "Two", "body": "### Two" } } } }"####; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); let version = Version::parse("0.2.0").unwrap(); let metadata = manifest.metadata_for_version(&version).expect("metadata"); assert_eq!(metadata.notes_body(), "### Changed\n- Two"); assert_eq!( metadata .announcement .as_ref() .and_then(|announcement| announcement.get("id")) .and_then(serde_json::Value::as_str), Some("two") ); } #[test] fn update_manifest_root_metadata_wins_for_latest_version() { let json = r####"{ "version": "0.3.0", "protocol": 4, "notes": "### Changed\n- Root", "announcement": { "id": "root", "title": "Root", "body": "### Root" }, "assets": { "linux_x86_64": "https://example.com/unused" }, "releases": { "0.3.0": { "notes": "### Changed\n- Stale", "announcement": { "id": "stale", "title": "Stale", "body": "### Stale" } } } }"####; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); let version = Version::parse("0.3.0").unwrap(); let metadata = manifest.metadata_for_version(&version).expect("metadata"); assert_eq!(metadata.notes_body(), "### Changed\n- Root"); assert_eq!( metadata .announcement .as_ref() .and_then(|announcement| announcement.get("id")) .and_then(serde_json::Value::as_str), Some("root") ); } #[test] fn update_manifest_ignores_malformed_releases_container() { let json = r####"{ "version": "0.3.0", "protocol": 4, "notes": "### Changed\n- Root", "assets": { "linux_x86_64": "https://example.com/unused" }, "releases": [] }"####; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert!(manifest.releases.is_empty()); assert_eq!( manifest .metadata_for_version(&Version::parse("0.3.0").unwrap()) .expect("metadata") .notes_body(), "### Changed\n- Root" ); } #[test] fn update_manifest_requires_notes_field() { let json = r#"{ "version": "0.2.0", "assets": { "linux-x86_64": "https://example.com/herdr-linux-x86_64" } }"#; assert!(serde_json::from_str::(json).is_err()); } #[test] fn stable_update_requires_asset_checksum() { let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); let json = format!( r####"{{ "version": "99.99.99", "notes": "### Changed\n- One", "assets": {{ "{asset_key}": "https://example.com/herdr" }} }}"#### ); let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); assert!(release_info_from_manifest(&manifest) .unwrap_err() .contains("missing a SHA-256 checksum")); } #[test] fn invalid_manifest_announcement_does_not_block_release_info() { let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); let json = format!( r####"{{ "version": "99.99.99", "protocol": 4, "notes": "### Changed\n- One", "announcement": {{ "id": 123, "title": "Keymap changes", "body": "### Heads up\n- Defaults changed" }}, "assets": {{ "{asset_key}": {{ "url": "https://example.com/herdr", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }} }} }}"#### ); let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); handle_manifest_announcement(&manifest.version, manifest.announcement.as_ref()); let release = release_info_from_manifest(&manifest) .unwrap() .expect("release info"); assert_eq!(release.version, Version::parse("99.99.99").unwrap()); assert_eq!(release.download_url, "https://example.com/herdr"); } #[test] fn stable_channel_installs_stable_asset_when_current_binary_is_preview() { let latest_stable = Version::parse("0.6.6").unwrap(); let installed_base = Version::parse("0.6.6").unwrap(); assert!(stable_channel_should_install( &latest_stable, &installed_base, true )); assert!(!stable_channel_should_install( &latest_stable, &installed_base, false )); } #[test] fn preview_manifest_reports_update_when_build_id_differs() { let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); let json = format!( r####"{{ "channel": "preview", "base_version": "9.9.9", "build_id": "2026-06-02-abcdef123456", "commit": "abcdef1234567890", "built_at": "2026-06-02T03:00:00Z", "protocol": 77, "notes": "### Fixed\n- One", "assets": {{ "{asset_key}": {{ "url": "https://example.com/herdr-linux-x86_64", "sha256": "deadbeef" }} }}, "builds": {{ "2026-06-02-abcdef123456": {{ "base_version": "9.9.9", "commit": "abcdef1234567890", "built_at": "2026-06-02T03:00:00Z", "protocol": 77, "assets": {{ "{asset_key}": {{ "url": "https://example.com/herdr-linux_x86_64", "sha256": "deadbeef" }} }} }} }} }}"#### ); let manifest: PreviewManifest = serde_json::from_str(&json).unwrap(); let release = release_info_from_preview_manifest(&manifest) .unwrap() .expect("preview update"); assert_eq!(release.channel, UpdateChannel::Preview); assert_eq!(release.identity, "9.9.9-preview.2026-06-02-abcdef123456"); assert_eq!(release.target_protocol, Some(77)); assert_eq!(release.sha256.as_deref(), Some("deadbeef")); } #[test] fn preview_windows_build_waits_for_first_stable_asset() { let without_windows: UpdateManifest = serde_json::from_str( r#"{"version":"9.9.9","notes":"notes","assets":{},"announcement":null}"#, ) .unwrap(); assert!(first_windows_stable_is_pending( &without_windows, true, true )); assert!(!first_windows_stable_is_pending( &without_windows, true, false )); assert!(!first_windows_stable_is_pending( &without_windows, false, true )); let with_windows: UpdateManifest = serde_json::from_str( r#"{"version":"9.9.9","notes":"notes","assets":{"windows-x86_64":"https://example.com/herdr-windows-x86_64.zip"},"announcement":null}"#, ) .unwrap(); assert!(!first_windows_stable_is_pending(&with_windows, true, true)); } #[test] fn checked_in_distribution_manifest_matches_update_schema() { #[derive(Deserialize)] struct LegacyUpdateManifest { assets: BTreeMap, } let json = include_str!("../distribution/latest.json"); let legacy: LegacyUpdateManifest = serde_json::from_str(json) .expect("distribution/latest.json should keep legacy string asset URLs"); assert!(legacy.assets.len() >= 4); let manifest: UpdateManifest = serde_json::from_str(json) .expect("distribution/latest.json should match updater schema"); assert!(!manifest .metadata_for_version(&Version::parse(&manifest.version).unwrap()) .expect("metadata") .notes_body() .is_empty()); // distribution/latest.json describes the latest released binaries, not the // current unreleased checkout. Its protocol is updated by the release // flow together with the release assets. assert!(manifest.protocol.is_some()); assert!(manifest.assets.len() >= 4); assert!(manifest.releases.contains_key(&manifest.version)); for target in [ "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", ] { let asset = manifest .assets .get(target) .unwrap_or_else(|| panic!("missing asset URL for {target}")); let url = &asset.url; assert_eq!( manifest.sha256.get(target).map(String::len), Some(64), "missing SHA-256 checksum for {target}" ); assert!( url.contains(&format!("/releases/download/v{}/", manifest.version)), "unexpected release URL for {target}: {url}" ); assert!( url.ends_with(&format!("herdr-{target}")), "unexpected asset name for {target}: {url}" ); } if let Some(windows) = manifest.assets.get("windows-x86_64") { assert!(windows.url.ends_with("/herdr-windows-x86_64.zip")); assert_eq!( manifest.sha256.get("windows-x86_64").map(String::len), Some(64), "missing SHA-256 checksum for windows-x86_64" ); } for (version, release) in &manifest.releases { let assets = release .get("assets") .and_then(serde_json::Value::as_object) .unwrap_or_else(|| panic!("missing assets for release {version}")); for target in [ "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", ] { let asset = assets .get(target) .cloned() .unwrap_or_else(|| panic!("missing asset URL for {version} {target}")); let asset: AssetRef = serde_json::from_value(asset) .unwrap_or_else(|_| panic!("invalid asset for {version} {target}")); let url = &asset.url; assert!( url.contains(&format!("/releases/download/v{version}/")), "unexpected release URL for {version} {target}: {url}" ); assert!( url.ends_with(&format!("herdr-{target}")), "unexpected asset name for {version} {target}: {url}" ); } if let Some(windows) = assets.get("windows-x86_64") { let windows: AssetRef = serde_json::from_value(windows.clone()) .unwrap_or_else(|_| panic!("invalid Windows asset for release {version}")); assert!(windows.url.ends_with("/herdr-windows-x86_64.zip")); let checksums = release .get("sha256") .and_then(serde_json::Value::as_object) .unwrap_or_else(|| panic!("missing checksums for release {version}")); assert!(checksums .get("windows-x86_64") .and_then(serde_json::Value::as_str) .is_some_and(|value| value.len() == 64)); } } } }