mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
3340 lines
111 KiB
Rust
3340 lines
111 KiB
Rust
//! 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;
|
|
use std::io::{self, BufRead, BufReader, IsTerminal, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
|
|
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";
|
|
const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const SERVER_HANDOFF_REQUEST_TIMEOUT: Duration = Duration::from_secs(240);
|
|
const SERVER_HANDOFF_CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
|
|
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<Self> {
|
|
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<String>,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for AssetRef {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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,
|
|
}),
|
|
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));
|
|
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()),
|
|
})
|
|
}
|
|
_ => Err(serde::de::Error::custom(
|
|
"asset must be a URL string or object with url",
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct UpdateManifest {
|
|
version: String,
|
|
/// Thin-client protocol spoken by this release, when advertised by the manifest.
|
|
protocol: Option<u32>,
|
|
notes: String,
|
|
assets: BTreeMap<String, AssetRef>,
|
|
announcement: Option<serde_json::Value>,
|
|
#[serde(default, deserialize_with = "deserialize_manifest_releases")]
|
|
releases: BTreeMap<String, serde_json::Value>,
|
|
}
|
|
|
|
fn deserialize_manifest_releases<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<BTreeMap<String, serde_json::Value>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let value = Option::<serde_json::Value>::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<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PreviewManifest {
|
|
channel: String,
|
|
base_version: String,
|
|
build_id: String,
|
|
commit: String,
|
|
built_at: String,
|
|
protocol: u32,
|
|
notes: String,
|
|
assets: BTreeMap<String, AssetRef>,
|
|
#[serde(default)]
|
|
builds: BTreeMap<String, PreviewBuildMetadata>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PreviewBuildMetadata {
|
|
base_version: String,
|
|
commit: String,
|
|
built_at: String,
|
|
protocol: u32,
|
|
assets: BTreeMap<String, AssetRef>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct HomebrewFormula {
|
|
versions: HomebrewFormulaVersions,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct HomebrewFormulaVersions {
|
|
stable: String,
|
|
}
|
|
|
|
impl UpdateManifest {
|
|
#[cfg(test)]
|
|
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
|
|
self.assets
|
|
.get(&format!("{os}-{arch}"))
|
|
.map(|asset| asset.url.clone())
|
|
}
|
|
|
|
fn metadata_for_version(&self, version: &Version) -> Option<ManifestReleaseMetadata> {
|
|
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::<ManifestReleaseMetadata>(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<String>,
|
|
commit: Option<String>,
|
|
target_protocol: Option<u32>,
|
|
download_url: String,
|
|
sha256: Option<String>,
|
|
notes_body: String,
|
|
}
|
|
|
|
impl ReleaseInfo {
|
|
fn label(&self) -> &str {
|
|
&self.identity
|
|
}
|
|
}
|
|
|
|
fn fetch_update_manifest() -> Result<UpdateManifest, String> {
|
|
fetch_json_manifest(STABLE_UPDATE_MANIFEST_URL)
|
|
}
|
|
|
|
fn fetch_preview_manifest() -> Result<PreviewManifest, String> {
|
|
fetch_json_manifest(PREVIEW_UPDATE_MANIFEST_URL)
|
|
}
|
|
|
|
fn fetch_json_manifest<T>(url: &str) -> Result<T, String>
|
|
where
|
|
T: serde::de::DeserializeOwned,
|
|
{
|
|
let output = Command::new("curl")
|
|
.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<Option<ReleaseInfo>, 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();
|
|
|
|
Ok(Some(ReleaseInfo {
|
|
identity: latest.to_string(),
|
|
version: latest,
|
|
channel: UpdateChannel::Stable,
|
|
build_id: None,
|
|
commit: None,
|
|
target_protocol: manifest.protocol,
|
|
download_url,
|
|
sha256: asset.sha256.clone(),
|
|
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<Option<ReleaseInfo>, 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
|
|
{
|
|
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()),
|
|
target_protocol: Some(manifest.protocol),
|
|
download_url,
|
|
sha256: asset.sha256.clone(),
|
|
notes_body,
|
|
}))
|
|
}
|
|
|
|
/// Check the hosted update manifest for the latest release. Returns release info if newer.
|
|
fn check_latest() -> Result<Option<ReleaseInfo>, String> {
|
|
let channel = UpdateChannel::configured();
|
|
if channel == UpdateChannel::Preview {
|
|
return release_info_from_preview_manifest(&fetch_preview_manifest()?);
|
|
}
|
|
|
|
let manifest = fetch_update_manifest()?;
|
|
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<Version, String> {
|
|
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<Option<Version>, String> {
|
|
let latest = parse_homebrew_formula_stable_version(input)?;
|
|
if &latest <= current {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(latest))
|
|
}
|
|
|
|
fn check_homebrew_latest() -> Result<Option<Version>, String> {
|
|
let current = Version::current();
|
|
|
|
let output = Command::new("curl")
|
|
.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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct DownloadedUpdate {
|
|
current_exe: PathBuf,
|
|
tmp_path: Option<PathBuf>,
|
|
}
|
|
|
|
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.
|
|
fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> {
|
|
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 = Command::new("curl")
|
|
.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),
|
|
})
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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())
|
|
}
|
|
|
|
fn client_protocol_server_is_running_at(socket_path: &Path) -> bool {
|
|
if !socket_path.exists() {
|
|
return false;
|
|
}
|
|
|
|
UnixStream::connect(socket_path).is_ok()
|
|
}
|
|
|
|
fn client_protocol_server_is_running() -> bool {
|
|
client_protocol_server_is_running_at(&crate::server::socket_paths::client_socket_path())
|
|
}
|
|
|
|
fn version_label(version: Option<&str>) -> &str {
|
|
version.unwrap_or("unknown")
|
|
}
|
|
|
|
fn update_requires_server_restart(
|
|
server: &crate::api::RuntimeStatus,
|
|
release: &ReleaseInfo,
|
|
) -> bool {
|
|
match (server.protocol, release.target_protocol) {
|
|
(Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol,
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn server_supports_live_handoff(server: &crate::api::RuntimeStatus) -> bool {
|
|
server
|
|
.capabilities
|
|
.as_ref()
|
|
.is_some_and(|capabilities| capabilities.live_handoff)
|
|
}
|
|
|
|
fn parse_stop_old_servers_after_update_response(input: &str, default_yes: bool) -> Option<bool> {
|
|
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)]
|
|
struct RunningServerUpdatePlan {
|
|
target: RunningUpdateTarget,
|
|
server: crate::api::RuntimeStatus,
|
|
requires_server_restart: bool,
|
|
}
|
|
|
|
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<String> {
|
|
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)]
|
|
struct RunningServerUpdateDecision {
|
|
plan: RunningServerUpdatePlan,
|
|
action: RunningServerUpdateAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RunningServerUpdateAction {
|
|
None,
|
|
LiveHandoff,
|
|
StopOldServer,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RunningServerUpdateOutcome {
|
|
RestartDeferred,
|
|
Stopped,
|
|
LiveHandoffComplete,
|
|
FailedHandoffOldServerKept,
|
|
FailedHandoffOldServerStopped,
|
|
FailedHandoffNoServer,
|
|
FailedHandoffUnknown,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct RunningSessionUpdateOutcome {
|
|
session_label: String,
|
|
target_noun: &'static str,
|
|
stop_command: String,
|
|
attach_command: Option<String>,
|
|
server_version: Option<String>,
|
|
outcome: RunningServerUpdateOutcome,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum FailedHandoffServerState {
|
|
UpdatedServerRunning,
|
|
OldServerRunning(crate::api::RuntimeStatus),
|
|
NoServerResponding,
|
|
Unknown(String),
|
|
}
|
|
|
|
fn plan_running_server_updates(
|
|
release: &ReleaseInfo,
|
|
) -> Result<Vec<RunningServerUpdatePlan>, 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)]
|
|
struct RunningUpdateTarget {
|
|
name: Option<String>,
|
|
label: String,
|
|
stop_command: String,
|
|
attach_command: Option<String>,
|
|
socket_path: PathBuf,
|
|
client_socket_path: PathBuf,
|
|
must_be_running: bool,
|
|
}
|
|
|
|
fn running_update_targets() -> Result<Vec<RunningUpdateTarget>, 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())
|
|
}
|
|
|
|
fn target_client_protocol_server_is_running() -> Result<bool, String> {
|
|
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<SelfUpdateOptions, String> {
|
|
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)
|
|
}
|
|
|
|
fn prompt_to_stop_old_servers_before_update(
|
|
plans: &[RunningServerUpdatePlan],
|
|
release: &ReleaseInfo,
|
|
) -> Result<bool, String> {
|
|
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"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn confirm_running_server_update_action(
|
|
plans: Vec<RunningServerUpdatePlan>,
|
|
release: &ReleaseInfo,
|
|
options: SelfUpdateOptions,
|
|
) -> Result<Vec<RunningServerUpdateDecision>, 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<RunningServerUpdatePlan> = 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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
fn prompt_to_complete_plain_update(
|
|
decisions: &[RunningServerUpdateDecision],
|
|
release: &ReleaseInfo,
|
|
) -> Result<bool, String> {
|
|
if decisions.is_empty() {
|
|
return Ok(true);
|
|
}
|
|
|
|
if !io::stdin().is_terminal() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let plans: Vec<&RunningServerUpdatePlan> =
|
|
decisions.iter().map(|decision| &decision.plan).collect();
|
|
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");
|
|
}
|
|
}
|
|
|
|
fn mark_plain_update_stop_decisions(
|
|
decisions: Vec<RunningServerUpdateDecision>,
|
|
) -> Vec<RunningServerUpdateDecision> {
|
|
decisions
|
|
.into_iter()
|
|
.map(|mut decision| {
|
|
decision.action = RunningServerUpdateAction::StopOldServer;
|
|
decision
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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!();
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()),
|
|
}
|
|
}
|
|
|
|
fn prompt_to_stop_old_server_after_failed_handoff(
|
|
plan: &RunningServerUpdatePlan,
|
|
release: &ReleaseInfo,
|
|
status: &crate::api::RuntimeStatus,
|
|
) -> Result<bool, String> {
|
|
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"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn recover_failed_live_handoff_for_update(
|
|
plan: &RunningServerUpdatePlan,
|
|
release: &ReleaseInfo,
|
|
error: &str,
|
|
) -> Result<RunningServerUpdateOutcome, String> {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
)
|
|
}
|
|
}
|
|
|
|
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",
|
|
)
|
|
}
|
|
|
|
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 = UnixStream::connect(socket_path)
|
|
.map_err(|e| format!("failed to connect to running server: {e}"))?;
|
|
stream
|
|
.set_write_timeout(Some(timeout))
|
|
.map_err(|e| format!("failed to set {error_prefix} write timeout: {e}"))?;
|
|
stream
|
|
.set_read_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(test)]
|
|
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",
|
|
)
|
|
}
|
|
|
|
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",
|
|
)
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
|
|
if !socket_path.exists() {
|
|
return Ok(true);
|
|
}
|
|
|
|
match UnixStream::connect(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()
|
|
)),
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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()),
|
|
)
|
|
}
|
|
|
|
fn wait_for_running_server_protocol_at(
|
|
socket_path: &Path,
|
|
timeout: Duration,
|
|
expected_protocol: Option<u32>,
|
|
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);
|
|
}
|
|
}
|
|
|
|
fn apply_running_session_update_decisions(
|
|
release: &ReleaseInfo,
|
|
updated_exe: &Path,
|
|
decisions: Vec<RunningServerUpdateDecision>,
|
|
) -> Result<Vec<RunningSessionUpdateOutcome>, String> {
|
|
let mut outcomes = Vec::new();
|
|
|
|
for decision in decisions {
|
|
let outcome = match decision.action {
|
|
RunningServerUpdateAction::None => RunningServerUpdateOutcome::RestartDeferred,
|
|
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)
|
|
}
|
|
|
|
fn print_running_session_update_outcomes(
|
|
outcomes: &[RunningSessionUpdateOutcome],
|
|
release: &ReleaseInfo,
|
|
) {
|
|
if outcomes.is_empty() {
|
|
eprintln!("run herdr again.");
|
|
return;
|
|
}
|
|
|
|
for outcome in outcomes {
|
|
match outcome.outcome {
|
|
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 follow its restart guidance".to_string()
|
|
}
|
|
HOMEBREW_UPDATE_COMMAND => {
|
|
"detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready".to_string()
|
|
}
|
|
MISE_UPDATE_COMMAND => {
|
|
"detach, run `mise upgrade herdr`, then restart this Herdr session when ready"
|
|
.to_string()
|
|
}
|
|
NIX_UPDATE_COMMAND => {
|
|
"detach, update through Nix, then restart this Herdr session when ready".to_string()
|
|
}
|
|
command => format!("detach, run `{command}`, then restart this Herdr session when ready"),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<Version, String> {
|
|
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);
|
|
}
|
|
};
|
|
|
|
let running_server_plans = plan_running_server_updates(&release)?;
|
|
let server_update_decisions =
|
|
confirm_running_server_update_action(running_server_plans, &release, options)?;
|
|
|
|
if let Some(commit) = &release.commit {
|
|
tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build");
|
|
}
|
|
eprintln!("downloading {}...", release.label());
|
|
if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
|
|
tracing::warn!("failed to save pending release notes: {e}");
|
|
}
|
|
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);
|
|
|
|
Ok(release.version)
|
|
}
|
|
|
|
fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) {
|
|
let status = Command::new(updated_exe)
|
|
.args(["integration", "status", "--outdated-only"])
|
|
.status();
|
|
|
|
if !status.is_ok_and(|status| status.success()) {
|
|
crate::integration::print_outdated_update_notice();
|
|
}
|
|
}
|
|
|
|
/// Background update check: only surface availability and release notes.
|
|
/// Runs in a background thread at startup.
|
|
pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
|
|
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<crate::events::AppEvent>) {
|
|
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 {
|
|
"unknown"
|
|
};
|
|
|
|
let arch = if cfg!(target_arch = "x86_64") {
|
|
"x86_64"
|
|
} else if cfg!(target_arch = "aarch64") {
|
|
"aarch64"
|
|
} else {
|
|
"unknown"
|
|
};
|
|
|
|
(os, arch)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
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 env_lock() -> &'static Mutex<()> {
|
|
static LOCK: OnceLock<Mutex<()>> = 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<AtomicBool>, 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<u32>) -> ReleaseInfo {
|
|
ReleaseInfo {
|
|
version: Version::parse(version).unwrap(),
|
|
identity: version.to_string(),
|
|
channel: UpdateChannel::Stable,
|
|
build_id: None,
|
|
commit: None,
|
|
target_protocol,
|
|
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 follow its restart guidance"
|
|
);
|
|
assert_eq!(
|
|
update_install_instruction(HOMEBREW_UPDATE_COMMAND),
|
|
"detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready"
|
|
);
|
|
assert_eq!(
|
|
update_install_instruction(MISE_UPDATE_COMMAND),
|
|
"detach, run `mise upgrade herdr`, then restart this Herdr session when ready"
|
|
);
|
|
}
|
|
|
|
#[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_when_target_protocol_differs_or_unknown() {
|
|
let server = crate::api::RuntimeStatus {
|
|
version: Some("0.5.5".to_string()),
|
|
protocol: Some(2),
|
|
capabilities: None,
|
|
};
|
|
let compatible_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(2),
|
|
download_url: "https://example.com/herdr".to_string(),
|
|
sha256: None,
|
|
notes_body: "### Changed\n- One".to_string(),
|
|
};
|
|
let incompatible_release = ReleaseInfo {
|
|
target_protocol: Some(4),
|
|
..compatible_release.clone()
|
|
};
|
|
let unknown_release = ReleaseInfo {
|
|
target_protocol: None,
|
|
..compatible_release.clone()
|
|
};
|
|
|
|
assert!(!update_requires_server_restart(
|
|
&server,
|
|
&compatible_release
|
|
));
|
|
assert!(update_requires_server_restart(
|
|
&server,
|
|
&incompatible_release
|
|
));
|
|
assert!(update_requires_server_restart(&server, &unknown_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 }),
|
|
},
|
|
};
|
|
|
|
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 release = fake_release("9.8.7", Some(77));
|
|
|
|
let err = plan_running_server_updates(&release).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),
|
|
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 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),
|
|
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\
|
|
\"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.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::<UpdateManifest>(json).is_err());
|
|
}
|
|
|
|
#[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}": "https://example.com/herdr"
|
|
}}
|
|
}}"####
|
|
);
|
|
|
|
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 checked_in_website_manifest_matches_update_schema() {
|
|
let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json"))
|
|
.expect("website/latest.json should match updater schema");
|
|
|
|
assert!(!manifest
|
|
.metadata_for_version(&Version::parse(&manifest.version).unwrap())
|
|
.expect("metadata")
|
|
.notes_body()
|
|
.is_empty());
|
|
// website/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_eq!(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 url = &manifest
|
|
.assets
|
|
.get(target)
|
|
.unwrap_or_else(|| panic!("missing asset URL for {target}"))
|
|
.url;
|
|
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}"
|
|
);
|
|
}
|
|
|
|
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 url = assets
|
|
.get(target)
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or_else(|| panic!("missing asset URL for {version} {target}"));
|
|
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}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|