aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
commitb751d102f158968aab681cb663d27b4c32197f62 (patch)
tree85d03609314559df7705c2cb0ab4e37ab6d9715f /src
parent295b09ca25edbccac65401ee84fa420dd3888aad (diff)
feat: add preview update channel
Diffstat (limited to 'src')
-rw-r--r--src/api/server.rs2
-rw-r--r--src/build_info.rs44
-rw-r--r--src/checksum.rs67
-rw-r--r--src/cli.rs104
-rw-r--r--src/cli/status.rs22
-rw-r--r--src/config.rs2
-rw-r--r--src/config/io.rs16
-rw-r--r--src/config/model.rs46
-rw-r--r--src/main.rs20
-rw-r--r--src/product_announcements.rs4
-rw-r--r--src/release_notes.rs4
-rw-r--r--src/remote.rs276
-rw-r--r--src/server/autodetect.rs2
-rw-r--r--src/server/handoff.rs6
-rw-r--r--src/update.rs419
15 files changed, 913 insertions, 121 deletions
diff --git a/src/api/server.rs b/src/api/server.rs
index 8b4362fb..c51b46e8 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -252,7 +252,7 @@ fn handle_request(
Method::Ping(_) => serde_json::to_string(&SuccessResponse {
id: request.id,
result: ResponseResult::Pong {
- version: env!("CARGO_PKG_VERSION").into(),
+ version: crate::build_info::version(),
protocol: crate::protocol::PROTOCOL_VERSION,
capabilities,
},
diff --git a/src/build_info.rs b/src/build_info.rs
new file mode 100644
index 00000000..1fdedfbb
--- /dev/null
+++ b/src/build_info.rs
@@ -0,0 +1,44 @@
+//! Build identity helpers.
+
+pub const BASE_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+pub fn channel() -> &'static str {
+ non_empty(option_env!("HERDR_BUILD_CHANNEL")).unwrap_or("stable")
+}
+
+pub fn build_id() -> Option<&'static str> {
+ non_empty(option_env!("HERDR_BUILD_ID"))
+}
+
+pub fn version() -> String {
+ match channel() {
+ "stable" => BASE_VERSION.to_string(),
+ channel => match build_id() {
+ Some(build_id) => format!("{BASE_VERSION}-{channel}.{build_id}"),
+ None => format!("{BASE_VERSION}-{channel}"),
+ },
+ }
+}
+
+pub fn is_preview() -> bool {
+ channel() == "preview"
+}
+
+fn non_empty(value: Option<&'static str>) -> Option<&'static str> {
+ value.and_then(|value| {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed)
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn stable_version_defaults_to_cargo_version() {
+ assert!(!super::version().is_empty());
+ }
+}
diff --git a/src/checksum.rs b/src/checksum.rs
new file mode 100644
index 00000000..4f61a33c
--- /dev/null
+++ b/src/checksum.rs
@@ -0,0 +1,67 @@
+use std::{
+ fs::File,
+ io::{self, Read},
+ path::Path,
+};
+
+use sha2::{Digest, Sha256};
+
+pub(crate) fn verify_sha256(path: &Path, expected: &str) -> io::Result<()> {
+ let expected = expected.trim().to_ascii_lowercase();
+ if expected.len() != 64 || !expected.chars().all(|ch| ch.is_ascii_hexdigit()) {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "expected sha256 must be 64 hexadecimal characters",
+ ));
+ }
+
+ let actual = file_sha256(path)?;
+ if actual != expected {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("sha256 mismatch: expected {expected}, got {actual}"),
+ ));
+ }
+ Ok(())
+}
+
+fn file_sha256(path: &Path) -> io::Result<String> {
+ let mut file = File::open(path)?;
+ let mut hasher = Sha256::new();
+ let mut buffer = [0u8; 64 * 1024];
+ loop {
+ let read = file.read(&mut buffer)?;
+ if read == 0 {
+ break;
+ }
+ hasher.update(&buffer[..read]);
+ }
+ Ok(to_lower_hex(&hasher.finalize()))
+}
+
+fn to_lower_hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for &byte in bytes {
+ output.push(HEX[(byte >> 4) as usize] as char);
+ output.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ #[test]
+ fn verifies_matching_sha256() {
+ let path = std::env::temp_dir().join(format!("herdr-checksum-test-{}", std::process::id()));
+ fs::write(&path, b"herdr").unwrap();
+ let result = super::verify_sha256(
+ &path,
+ "78193ef266c1e3c2ce4ea2a86d7fc87e8c52799653faaac8536533a1c9300f82",
+ );
+ let _ = fs::remove_file(&path);
+ assert!(result.is_ok());
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 2d9dd337..b6e2fc94 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -36,6 +36,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
}
"status" => status::run_status_command(&args[2..])?,
"config" => run_config_command(&args[2..])?,
+ "channel" => run_channel_command(&args[2..])?,
"workspace" => workspace::run_workspace_command(&args[2..])?,
"worktree" => worktree::run_worktree_command(&args[2..])?,
"tab" => tab::run_tab_command(&args[2..])?,
@@ -51,6 +52,89 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
Ok(CommandOutcome::Handled(exit_code))
}
+fn run_channel_command(args: &[String]) -> std::io::Result<i32> {
+ match args.first().map(|arg| arg.as_str()) {
+ Some("set") => channel_set(&args[1..]),
+ Some("show") if args.len() == 1 => {
+ let config = crate::config::Config::load().config;
+ println!("{}", config.update.channel.as_str());
+ Ok(0)
+ }
+ Some("help" | "--help" | "-h") => {
+ print_channel_help();
+ Ok(0)
+ }
+ _ => {
+ print_channel_help();
+ Ok(2)
+ }
+ }
+}
+
+fn channel_set(args: &[String]) -> std::io::Result<i32> {
+ let Some(channel) = parse_channel_set_arg(args) else {
+ eprintln!("usage: herdr channel set <stable|preview>");
+ return Ok(2);
+ };
+
+ let path = crate::config::config_path();
+ let content = if path.exists() {
+ std::fs::read_to_string(&path)?
+ } else {
+ String::new()
+ };
+ if let Err(err) = content.parse::<toml::Value>() {
+ eprintln!(
+ "config file at {} is invalid TOML: {err}. Fix it before changing the update channel.",
+ path.display()
+ );
+ return Ok(1);
+ }
+
+ let updated = crate::config::upsert_section_value(
+ &content,
+ "update",
+ "channel",
+ &format!("\"{channel}\""),
+ );
+ if let Err(err) = updated.parse::<toml::Value>() {
+ eprintln!(
+ "changing the update channel would make {} invalid TOML: {err}; leaving config unchanged",
+ path.display()
+ );
+ return Ok(1);
+ }
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(&path, updated)?;
+ println!(
+ "Herdr update channel set to {channel} in {}.",
+ path.display()
+ );
+ if channel == "preview" {
+ println!("Run `herdr update` to install the latest preview build when ready.");
+ } else {
+ println!("Run `herdr update` to install the latest stable release when ready.");
+ }
+ Ok(0)
+}
+
+fn parse_channel_set_arg(args: &[String]) -> Option<&str> {
+ let channel = args.first().map(|arg| arg.as_str())?;
+ if args.len() == 1 && matches!(channel, "stable" | "preview") {
+ Some(channel)
+ } else {
+ None
+ }
+}
+
+fn print_channel_help() {
+ eprintln!("herdr channel commands:");
+ eprintln!(" herdr channel show print the configured update channel");
+ eprintln!(" herdr channel set <stable|preview> choose the update channel");
+}
+
fn run_config_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_config_help();
@@ -745,3 +829,23 @@ fn print_session_help() {
fn _print_json<T: Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn parses_channel_set_argument() {
+ assert_eq!(
+ super::parse_channel_set_arg(&["preview".to_string()]),
+ Some("preview")
+ );
+ assert_eq!(
+ super::parse_channel_set_arg(&["stable".to_string()]),
+ Some("stable")
+ );
+ assert_eq!(super::parse_channel_set_arg(&["nightly".to_string()]), None);
+ assert_eq!(
+ super::parse_channel_set_arg(&["preview".to_string(), "stable".to_string()]),
+ None
+ );
+ }
+}
diff --git a/src/cli/status.rs b/src/cli/status.rs
index 0062367a..348746c5 100644
--- a/src/cli/status.rs
+++ b/src/cli/status.rs
@@ -92,7 +92,11 @@ fn print_full_status(json: bool) -> std::io::Result<i32> {
}
println!("client:");
- println!(" version: {}", env!("CARGO_PKG_VERSION"));
+ println!(" version: {}", crate::build_info::version());
+ println!(
+ " channel: {}",
+ crate::config::Config::load().config.update.channel.as_str()
+ );
println!(" protocol: {}", crate::protocol::PROTOCOL_VERSION);
println!();
println!("server:");
@@ -120,7 +124,11 @@ fn print_client_status(json: bool) -> std::io::Result<()> {
return Ok(());
}
- println!("version: {}", env!("CARGO_PKG_VERSION"));
+ println!("version: {}", crate::build_info::version());
+ println!(
+ "channel: {}",
+ crate::config::Config::load().config.update.channel.as_str()
+ );
println!("protocol: {}", crate::protocol::PROTOCOL_VERSION);
println!("binary: {}", current_exe_label());
Ok(())
@@ -193,7 +201,7 @@ fn compatibility_label(protocol: Option<u32>) -> &'static str {
fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str {
match server {
ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
- Some(env!("CARGO_PKG_VERSION")) => "no",
+ Some(version) if version == crate::build_info::version() => "no",
Some(_) => "yes",
None => "unknown",
},
@@ -210,7 +218,8 @@ struct FullStatusJson {
#[derive(Serialize)]
struct ClientStatusJson {
- version: &'static str,
+ version: String,
+ channel: &'static str,
protocol: u32,
binary: String,
session: Option<String>,
@@ -241,7 +250,8 @@ struct UpdateStatusJson {
fn client_status_json() -> ClientStatusJson {
ClientStatusJson {
- version: env!("CARGO_PKG_VERSION"),
+ version: crate::build_info::version(),
+ channel: crate::config::Config::load().config.update.channel.as_str(),
protocol: crate::protocol::PROTOCOL_VERSION,
binary: current_exe_label(),
session: crate::session::active_name(),
@@ -292,7 +302,7 @@ fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson {
fn restart_needed_bool(server: &ServerRuntimeStatus) -> Option<bool> {
match server {
ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
- Some(env!("CARGO_PKG_VERSION")) => Some(false),
+ Some(version) if version == crate::build_info::version() => Some(false),
Some(_) => Some(true),
None => None,
},
diff --git a/src/config.rs b/src/config.rs
index ab194cf5..4fc275c1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -20,7 +20,7 @@ pub use self::{
model::{
validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ShellModeConfig, ToastConfig,
- ToastDelivery,
+ ToastDelivery, UpdateChannelConfig,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
diff --git a/src/config/io.rs b/src/config/io.rs
index 2c6d78ab..1ea928a3 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -183,6 +183,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
);
load_live_section(
table,
+ "update",
+ "update config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.update = section,
+ );
+ load_live_section(
+ table,
"ui",
"ui config",
&mut diagnostics,
@@ -213,6 +221,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
&mut invalid_sections,
|section| config.experimental = section,
);
+ load_live_section(
+ table,
+ "remote",
+ "remote config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.remote = section,
+ );
Ok(LoadedConfig {
config,
diff --git a/src/config/model.rs b/src/config/model.rs
index f815fb8d..7895341f 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -10,6 +10,37 @@ use super::{
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
+pub enum UpdateChannelConfig {
+ #[default]
+ Stable,
+ Preview,
+}
+
+impl UpdateChannelConfig {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Stable => "stable",
+ Self::Preview => "preview",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+#[serde(default)]
+pub struct UpdateConfig {
+ pub channel: UpdateChannelConfig,
+}
+
+impl Default for UpdateConfig {
+ fn default() -> Self {
+ Self {
+ channel: UpdateChannelConfig::Stable,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
+#[serde(rename_all = "lowercase")]
pub enum ToastDelivery {
#[default]
Off,
@@ -187,6 +218,7 @@ pub struct Config {
pub theme: ThemeConfig,
pub terminal: TerminalConfig,
pub session: SessionConfig,
+ pub update: UpdateConfig,
pub keys: KeysConfig,
pub ui: UiConfig,
pub worktrees: WorktreesConfig,
@@ -589,6 +621,20 @@ mod tests {
use super::*;
#[test]
+ fn update_channel_defaults_stable_and_parses() {
+ let default_config = Config::default();
+ assert_eq!(default_config.update.channel, UpdateChannelConfig::Stable);
+
+ let toml = r#"
+[update]
+channel = "preview"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.update.channel, UpdateChannelConfig::Preview);
+ assert_eq!(config.update.channel.as_str(), "preview");
+ }
+
+ #[test]
fn terminal_default_shell_defaults_empty_and_parses() {
let default_config = Config::default();
assert!(default_config.terminal.default_shell.is_empty());
diff --git a/src/main.rs b/src/main.rs
index d7be42c6..13ba5e6f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,6 +21,8 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
mod agent_resume;
mod api;
mod app;
+mod build_info;
+mod checksum;
mod cli;
mod client;
mod config;
@@ -95,6 +97,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# "current" for Herdr's process directory, or a fixed path such as "~/Projects".
# new_cwd = "follow"
+[update]
+# Update channel used by background checks and `herdr update`.
+# Use "stable" for normal releases or "preview" for opt-in preview builds.
+# channel = "stable"
+
[keys]
# Prefix key to enter prefix mode (default: "ctrl+b")
# Examples: "ctrl+b", "f12", "esc", "-"
@@ -404,9 +411,11 @@ fn main() -> io::Result<()> {
println!(" herdr --remote <ssh-target> [--session <name>]");
println!(" herdr session attach <name>");
println!(" herdr update [--handoff]");
+ println!(" herdr channel set <stable|preview>");
println!(" herdr server stop");
println!(" herdr server reload-config");
println!(" herdr config <subcommand> ...");
+ println!(" herdr channel <subcommand> ...");
println!(" herdr workspace <subcommand> ...");
println!(" herdr worktree <subcommand> ...");
println!(" herdr tab <subcommand> ...");
@@ -429,6 +438,10 @@ fn main() -> io::Result<()> {
"Stop the running server via the API socket",
),
(
+ "herdr channel set <stable|preview>",
+ "Choose the stable or preview update channel",
+ ),
+ (
"herdr server reload-config",
"Reload config.toml in the running server",
),
@@ -437,6 +450,10 @@ fn main() -> io::Result<()> {
"Back up config.toml and remove custom keybindings",
),
(
+ "herdr channel <subcommand>",
+ "Manage the stable or preview update channel",
+ ),
+ (
"herdr workspace <subcommand>",
"Workspace helpers over the socket API",
),
@@ -491,7 +508,7 @@ fn main() -> io::Result<()> {
}
if args.iter().any(|a| a == "--version" || a == "-V") {
- println!("herdr {}", env!("CARGO_PKG_VERSION"));
+ println!("herdr {}", crate::build_info::version());
return Ok(());
}
@@ -527,6 +544,7 @@ fn main() -> io::Result<()> {
"update",
"status",
"config",
+ "channel",
"workspace",
"worktree",
"pane",
diff --git a/src/product_announcements.rs b/src/product_announcements.rs
index 0d62168c..fe338021 100644
--- a/src/product_announcements.rs
+++ b/src/product_announcements.rs
@@ -99,7 +99,7 @@ pub fn save_manifest_announcement(
pub fn load_unseen_for_current_version() -> Option<ProductAnnouncement> {
load_fake_for_current_version()
- .or_else(|| load_unseen_from_path(&store_path(), env!("CARGO_PKG_VERSION")))
+ .or_else(|| load_unseen_from_path(&store_path(), &crate::build_info::version()))
}
pub fn mark_seen(version: &str, id: &str) -> io::Result<()> {
@@ -155,7 +155,7 @@ fn load_fake_for_current_version() -> Option<ProductAnnouncement> {
.unwrap_or_else(|| "product announcement preview".to_string());
Some(ProductAnnouncement {
- version: env!("CARGO_PKG_VERSION").to_string(),
+ version: crate::build_info::version(),
id,
title,
body,
diff --git a/src/release_notes.rs b/src/release_notes.rs
index aeaef0b8..b211e63d 100644
--- a/src/release_notes.rs
+++ b/src/release_notes.rs
@@ -71,7 +71,7 @@ fn load_stored_from_path(path: &Path) -> Option<StoredReleaseNotes> {
}
pub fn load_latest() -> Option<ReleaseNotes> {
- load_latest_from_path(&pending_path(), env!("CARGO_PKG_VERSION"))
+ load_latest_from_path(&pending_path(), &crate::build_info::version())
}
fn load_latest_from_path(path: &Path, current_version: &str) -> Option<ReleaseNotes> {
@@ -104,7 +104,7 @@ fn release_notes_from_stored(
}
pub fn mark_current_version_seen() -> std::io::Result<()> {
- mark_current_version_seen_at(&pending_path(), env!("CARGO_PKG_VERSION"))
+ mark_current_version_seen_at(&pending_path(), &crate::build_info::version())
}
fn mark_current_version_seen_at(path: &Path, current_version: &str) -> std::io::Result<()> {
diff --git a/src/remote.rs b/src/remote.rs
index ebea55e0..cd53344b 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -19,9 +19,9 @@ const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
-const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
-const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
@@ -302,11 +302,36 @@ impl RemoteHerdr {
}
}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+enum RemoteAssetRef {
+ Url(String),
+ Object { url: String, sha256: Option<String> },
+}
+
+impl RemoteAssetRef {
+ fn url(&self) -> &str {
+ match self {
+ Self::Url(url) => url,
+ Self::Object { url, .. } => url,
+ }
+ }
+
+ fn sha256(&self) -> Option<&str> {
+ match self {
+ Self::Url(_) => None,
+ Self::Object { sha256, .. } => {
+ sha256.as_deref().filter(|value| !value.trim().is_empty())
+ }
+ }
+ }
+}
+
#[derive(Deserialize)]
struct RemoteUpdateManifest {
version: String,
protocol: Option<u32>,
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, RemoteAssetRef>,
#[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
releases: BTreeMap<String, RemoteReleaseMetadata>,
}
@@ -315,7 +340,22 @@ struct RemoteUpdateManifest {
struct RemoteReleaseMetadata {
protocol: Option<u32>,
#[serde(default)]
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, RemoteAssetRef>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewManifest {
+ build_id: String,
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
+ #[serde(default)]
+ builds: BTreeMap<String, RemotePreviewBuildMetadata>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewBuildMetadata {
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
}
fn deserialize_remote_manifest_releases<'de, D>(
@@ -359,7 +399,15 @@ impl RemoteUpdateManifest {
#[derive(Clone, Copy)]
struct RemoteManifestReleaseRef<'a> {
protocol: Option<u32>,
- assets: &'a BTreeMap<String, String>,
+ assets: &'a BTreeMap<String, RemoteAssetRef>,
+}
+
+fn current_version() -> String {
+ crate::build_info::version()
+}
+
+fn current_channel() -> &'static str {
+ crate::build_info::channel()
}
struct InstallSource {
@@ -367,6 +415,11 @@ struct InstallSource {
temporary_dir: Option<PathBuf>,
}
+struct RemoteReleaseAsset {
+ url: String,
+ sha256: Option<String>,
+}
+
struct PreparedRemoteHerdr {
remote_herdr: RemoteHerdr,
installed_or_replaced: bool,
@@ -448,8 +501,9 @@ fn prepare_remote_herdr(
if !remote_binary_matches(target, &remote_herdr)? {
return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it did not report version {CURRENT_VERSION}",
- remote_herdr.shell_path
+ "installed remote herdr at {}, but it did not report version {}",
+ remote_herdr.shell_path,
+ current_version()
)));
}
warn_if_remote_bin_not_on_path(target)?;
@@ -519,7 +573,7 @@ fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result
let mut lines = stdout.lines();
let version = lines.next().unwrap_or_default().trim();
let status = lines.next().unwrap_or_default();
- Ok(version == format!("herdr {CURRENT_VERSION}")
+ Ok(version == format!("herdr {}", current_version())
&& parse_client_status_json(status)
.map(|status| status.protocol == CURRENT_PROTOCOL)
.unwrap_or(false))
@@ -585,7 +639,9 @@ fn install_source_description_for(
"the current local herdr binary".to_string()
} else {
format!(
- "the {CURRENT_VERSION} release asset for {}",
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
platform.asset_key()
)
}
@@ -691,7 +747,7 @@ fn remote_server_restart_reason(
if remote_binary_changed {
return Some(RemoteServerRestartReason::BinaryUpdated);
}
- if version != Some(CURRENT_VERSION) {
+ if version != Some(current_version().as_str()) {
return Some(RemoteServerRestartReason::VersionMismatch);
}
None
@@ -750,7 +806,8 @@ fn confirm_remote_install_with_running_server(
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version.as_deref()));
eprintln!(
- "Herdr will install v{CURRENT_VERSION} and hand off live pane processes to the prepared server."
+ "Herdr will install {} and hand off live pane processes to the prepared server.",
+ current_version()
);
return Ok(false);
}
@@ -762,7 +819,10 @@ fn confirm_remote_install_with_running_server(
);
eprintln!("This stops active remote pane processes, including shells, dev servers, and tests.");
eprintln!();
- eprint!("Install v{CURRENT_VERSION} and stop the remote server now? [y/N] ");
+ eprint!(
+ "Install {} and stop the remote server now? [y/N] ",
+ current_version()
+ );
io::stderr().flush()?;
let mut answer = String::new();
@@ -847,15 +907,16 @@ fn confirm_remote_server_stop(
}
eprintln!(
- "remote herdr server on {target} is still running v{}; it will use v{CURRENT_VERSION} after it restarts.",
- version_label(version)
+ "remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
+ version_label(version),
+ current_version()
);
return Ok(false);
}
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version));
- eprintln!(" prepared binary: v{CURRENT_VERSION}");
+ eprintln!(" prepared binary: {}", current_version());
eprintln!();
match reason {
@@ -903,9 +964,11 @@ fn confirm_remote_server_stop(
fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let command = format!(
- "{} server live-handoff --import-exe {} --expected-protocol {CURRENT_PROTOCOL} --expected-version {CURRENT_VERSION}",
+ "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
remote_herdr.shell_path,
- remote_herdr.shell_path
+ remote_herdr.shell_path,
+ CURRENT_PROTOCOL,
+ current_version()
);
let output = ssh_sh_output(target, &command)?;
if !output.status.success() {
@@ -976,7 +1039,36 @@ fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
}
fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
- let manifest_output = Command::new("curl")
+ let asset_key = platform.asset_key();
+ let asset = remote_release_asset(&asset_key)?;
+
+ let dir = private_download_dir(&asset_key)?;
+ let path = dir.join("herdr.tmp");
+ let status = Command::new("curl")
+ .args(["-sfL", "--max-time", "120", "-o"])
+ .arg(&path)
+ .arg(&asset.url)
+ .status()
+ .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
+ if !status.success() {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::other("download failed"));
+ }
+ if let Some(expected) = &asset.sha256 {
+ if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::new(
+ err.kind(),
+ format!("downloaded remote asset checksum verification failed: {err}"),
+ ));
+ }
+ }
+
+ Ok(InstallSource::temporary(path, dir))
+}
+
+fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
+ let output = Command::new("curl")
.args([
"-sfL",
"--retry",
@@ -985,54 +1077,87 @@ fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource
"10",
"--max-time",
"20",
- UPDATE_MANIFEST_URL,
+ url,
])
.output()
.map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
- if !manifest_output.status.success() {
- return Err(command_failed(
- "failed to fetch update manifest",
- &manifest_output,
- ));
+ if !output.status.success() {
+ return Err(command_failed("failed to fetch update manifest", &output));
}
+ Ok(output.stdout)
+}
- let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_output.stdout)
- .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
+ RemoteReleaseAsset {
+ url: asset.url().to_string(),
+ sha256: asset.sha256().map(str::to_string),
+ }
+}
- let asset_key = platform.asset_key();
- let release = manifest.release_for_version(CURRENT_VERSION).ok_or_else(|| {
+fn preview_assets_for_build<'a>(
+ manifest: &'a RemotePreviewManifest,
+ build_id: &str,
+) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
+ if manifest.build_id == build_id {
+ return Ok((manifest.protocol, &manifest.assets));
+ }
+ let build = manifest.builds.get(build_id).ok_or_else(|| {
io::Error::other(format!(
- "release manifest does not include herdr {CURRENT_VERSION}; build herdr for {} or install it there manually",
- platform.asset_key()
+ "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
))
})?;
- if let Some(protocol) = release.protocol {
+ Ok((build.protocol, &build.assets))
+}
+
+fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
+ if crate::build_info::is_preview() {
+ let build_id = crate::build_info::build_id().ok_or_else(|| {
+ io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
+ })?;
+ let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
+ let manifest: RemotePreviewManifest =
+ serde_json::from_slice(&manifest_bytes).map_err(|err| {
+ io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
+ })?;
+ let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
if protocol != CURRENT_PROTOCOL {
return Err(io::Error::other(format!(
- "release manifest has herdr {CURRENT_VERSION} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
+ "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually"
)));
}
+ return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the preview manifest for build {build_id}"
+ ))
+ });
}
- let url = release.assets.get(&asset_key).ok_or_else(|| {
+
+ let current_version = current_version();
+ let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
+ let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
+ .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+ let release = manifest.release_for_version(&current_version).ok_or_else(|| {
io::Error::other(format!(
- "no {asset_key} binary in the release manifest for herdr {CURRENT_VERSION}"
+ "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
+ asset_key
))
})?;
-
- let dir = private_download_dir(&asset_key)?;
- let path = dir.join("herdr.tmp");
- let status = Command::new("curl")
- .args(["-sfL", "--max-time", "120", "-o"])
- .arg(&path)
- .arg(url)
- .status()
- .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
- if !status.success() {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::other("download failed"));
+ if let Some(protocol) = release.protocol {
+ if protocol != CURRENT_PROTOCOL {
+ return Err(io::Error::other(format!(
+ "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
+ )));
+ }
}
-
- Ok(InstallSource::temporary(path, dir))
+ release
+ .assets
+ .get(asset_key)
+ .map(remote_asset_info)
+ .ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the release manifest for herdr {current_version}"
+ ))
+ })
}
fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
@@ -1063,13 +1188,15 @@ fn confirm_remote_install(
) -> io::Result<()> {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
- "matching remote herdr {CURRENT_VERSION} is not installed at {}; run from an interactive terminal to approve installation",
+ "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
+ current_version(),
remote_herdr.shell_path
)));
}
eprintln!(
- "matching herdr {CURRENT_VERSION} is not installed on {target} for {}.",
+ "matching herdr {} is not installed on {target} for {}.",
+ current_version(),
remote_herdr.platform.asset_key()
);
eprint!(
@@ -2029,7 +2156,7 @@ mod tests {
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
- .map(String::as_str),
+ .map(RemoteAssetRef::url),
Some("https://example.com/latest")
);
}
@@ -2058,7 +2185,7 @@ mod tests {
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
- .map(String::as_str),
+ .map(RemoteAssetRef::url),
Some("https://example.com/archive")
);
}
@@ -2123,9 +2250,44 @@ mod tests {
}
#[test]
+ fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
+ let manifest: RemotePreviewManifest = serde_json::from_str(
+ r#"{
+ "build_id": "2026-06-06-new",
+ "protocol": 12,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/new",
+ "sha256": "new"
+ }
+ },
+ "builds": {
+ "2026-06-02-old": {
+ "protocol": 11,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/old",
+ "sha256": "old"
+ }
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ let (protocol, assets) =
+ preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
+ let asset = assets.get("linux-x86_64").expect("asset");
+ assert_eq!(protocol, 11);
+ assert_eq!(asset.url(), "https://example.com/old");
+ assert_eq!(asset.sha256(), Some("old"));
+ }
+
+ #[test]
fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(0), false),
+ remote_server_restart_reason(Some(&current_version()), Some(0), false),
Some(RemoteServerRestartReason::ProtocolMismatch)
);
}
@@ -2133,7 +2295,7 @@ mod tests {
#[test]
fn remote_server_restart_reason_offers_restart_after_binary_update() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), true),
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), true),
Some(RemoteServerRestartReason::BinaryUpdated)
);
}
@@ -2153,7 +2315,7 @@ mod tests {
#[test]
fn remote_server_restart_reason_allows_current_server() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), false),
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), false),
None
);
}
@@ -2187,7 +2349,9 @@ mod tests {
assert_eq!(
install_source_description_for(&platform, None, false),
format!(
- "the {CURRENT_VERSION} release asset for {}",
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
platform.asset_key()
)
);
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index c012aa03..a4ea721b 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -103,7 +103,7 @@ fn validate_running_server_compatibility() -> io::Result<()> {
.protocol
.map(|value| value.to_string())
.unwrap_or_else(|| "unknown".to_string()),
- env!("CARGO_PKG_VERSION"),
+ crate::build_info::version(),
crate::protocol::PROTOCOL_VERSION,
crate::session::active_restart_after_update_guidance()
)))
diff --git a/src/server/handoff.rs b/src/server/handoff.rs
index 22d1f902..01f9e678 100644
--- a/src/server/handoff.rs
+++ b/src/server/handoff.rs
@@ -245,12 +245,12 @@ pub(crate) fn receive(socket_path: &Path, token: &str) -> io::Result<ReceivedHan
if manifest
.expected_version
.as_deref()
- .is_some_and(|version| version != env!("CARGO_PKG_VERSION"))
+ .is_some_and(|version| version != crate::build_info::version())
{
return Err(io::Error::other(format!(
"handoff expected herdr v{}, but this server is v{}",
manifest.expected_version.as_deref().unwrap_or("unknown"),
- env!("CARGO_PKG_VERSION")
+ crate::build_info::version()
)));
}
stream.write_all(b"validated\n")?;
@@ -300,7 +300,7 @@ pub(crate) fn manifest_for(
) -> HandoffManifest {
HandoffManifest {
version: HANDOFF_VERSION,
- source_version: env!("CARGO_PKG_VERSION").to_string(),
+ source_version: crate::build_info::version(),
source_protocol: crate::protocol::PROTOCOL_VERSION,
expected_version,
expected_protocol,
diff --git a/src/update.rs b/src/update.rs
index d05365d1..58095c08 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -17,12 +17,12 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Deserializer};
-const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+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 NIX_UPDATE_COMMAND: &str = "update through Nix";
-const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
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";
@@ -73,7 +73,7 @@ impl Version {
}
pub fn current() -> Self {
- Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION")
+ Self::parse(crate::build_info::BASE_VERSION).expect("invalid CARGO_PKG_VERSION")
}
}
@@ -87,13 +87,75 @@ impl std::fmt::Display for Version {
// 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, String>,
+ assets: BTreeMap<String, AssetRef>,
announcement: Option<serde_json::Value>,
#[serde(default, deserialize_with = "deserialize_manifest_releases")]
releases: BTreeMap<String, serde_json::Value>,
@@ -119,6 +181,29 @@ struct ManifestReleaseMetadata {
}
#[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,
}
@@ -129,8 +214,11 @@ struct HomebrewFormulaVersions {
}
impl UpdateManifest {
+ #[cfg(test)]
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
- self.assets.get(&format!("{os}-{arch}")).cloned()
+ self.assets
+ .get(&format!("{os}-{arch}"))
+ .map(|asset| asset.url.clone())
}
fn metadata_for_version(&self, version: &Version) -> Option<ManifestReleaseMetadata> {
@@ -164,12 +252,34 @@ impl ManifestReleaseMetadata {
#[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",
@@ -179,7 +289,7 @@ fn fetch_update_manifest() -> Result<UpdateManifest, String> {
"10",
"--max-time",
"20",
- UPDATE_MANIFEST_URL,
+ url,
])
.output()
.map_err(|e| format!("curl failed: {e}"))?;
@@ -219,7 +329,7 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
let latest = Version::parse(&manifest.version)
.ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?;
- if latest <= current {
+ if !stable_channel_should_install(&latest, &current, crate::build_info::is_preview()) {
return Ok(None); // up to date
}
@@ -232,20 +342,117 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
}
let (os, arch) = platform_target();
- let download_url = manifest
- .download_url_for(os, arch)
- .ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?;
+ 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 {
@@ -357,6 +564,16 @@ fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> {
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)]
{
@@ -703,8 +920,8 @@ fn prompt_to_stop_old_servers_before_update(
}
eprintln!(
- "Running sessions that must stop to use v{}:",
- release.version
+ "Running sessions that must stop to use {}:",
+ release.label()
);
for plan in plans {
eprintln!(
@@ -838,8 +1055,9 @@ fn prompt_to_complete_plain_update(
loop {
eprint!(
- "Stop running {} and install v{} now? [y/N] ",
- noun, release.version
+ "Stop running {} and install {} now? [y/N] ",
+ noun,
+ release.label()
);
io::stderr()
.flush()
@@ -899,7 +1117,7 @@ fn print_running_session_update_summary(
);
}
}
- eprintln!(" update: v{}", release.version);
+ eprintln!(" update: {}", release.label());
eprintln!();
}
@@ -927,7 +1145,7 @@ fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &Release
let protocol_matches = release
.target_protocol
.is_none_or(|protocol| status.protocol == Some(protocol));
- let version_matches = status.version.as_deref() == Some(&release.version.to_string());
+ let version_matches = status.version.as_deref() == Some(release.label());
protocol_matches && version_matches
}
@@ -956,10 +1174,10 @@ fn prompt_to_stop_old_server_after_failed_handoff(
plan.label()
);
eprintln!(" server: v{}", version_label(status.version.as_deref()));
- eprintln!(" installed: v{}", release.version);
+ eprintln!(" installed: {}", release.label());
eprintln!(
- "you can keep using the old server, or stop it now so the next `herdr` start uses v{}.",
- release.version
+ "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.");
@@ -1024,15 +1242,15 @@ fn recover_failed_live_handoff_for_update(
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 v{}.",
+ "no herdr server is responding for session {}. the binary was updated; run `{command}` to start {}.",
plan.label(),
- release.version
+ release.label()
);
} else {
eprintln!(
- "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use v{}.",
+ "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use {}.",
plan.socket_path().display(),
- release.version
+ release.label()
);
}
Ok(RunningServerUpdateOutcome::FailedHandoffNoServer)
@@ -1154,7 +1372,7 @@ fn live_handoff_server_via_api_for_release_at(
let params = ServerLiveHandoffParams {
import_exe: Some(updated_exe.display().to_string()),
expected_protocol: release.target_protocol,
- expected_version: Some(release.version.to_string()),
+ expected_version: Some(release.label().to_string()),
};
send_server_update_method_at(
@@ -1236,7 +1454,7 @@ fn wait_for_server_handoff_at(
socket_path,
timeout,
release.target_protocol,
- Some(&release.version.to_string()),
+ Some(release.label()),
)
}
@@ -1341,12 +1559,14 @@ fn print_running_session_update_outcomes(
eprintln!("Stopping exits active pane processes.");
match &outcome.attach_command {
Some(command) => eprintln!(
- "Run `{}`, then run `{command}` when ready to use v{}.",
- outcome.stop_command, release.version
+ "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 v{}.",
- outcome.stop_command, release.version
+ "Run `{}`, then restart Herdr with the same socket override when ready to use {}.",
+ outcome.stop_command,
+ release.label()
),
}
}
@@ -1499,13 +1719,24 @@ fn homebrew_cellar_keg_root(path: &Path) -> Option<PathBuf> {
/// 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_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(),
);
@@ -1515,14 +1746,14 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
return Err("run `herdr update` outside herdr after detaching from the session".into());
}
- eprintln!("checking for updates...");
+ 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 (v{current})");
+ eprintln!("already up to date ({})", crate::build_info::version());
return Ok(current);
}
};
@@ -1531,15 +1762,16 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
let server_update_decisions =
confirm_running_server_update_action(running_server_plans, &release, options)?;
- eprintln!("downloading v{}...", release.version);
- if let Err(e) =
- crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
- {
+ 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 v{}", release.version);
+ eprintln!("downloaded {}", release.label());
if !options.live_handoff
&& !prompt_to_complete_plain_update(&server_update_decisions, &release)?
{
@@ -1548,7 +1780,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
return Ok(current);
}
install_downloaded_update(downloaded_update)?;
- eprintln!("installed v{}", release.version);
+ eprintln!("installed {}", release.label());
let server_update_decisions = if options.live_handoff {
server_update_decisions
} else {
@@ -1598,12 +1830,23 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
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;
}
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,
@@ -1614,22 +1857,20 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
};
- crate::logging::update_available(&release.version.to_string());
+ crate::logging::update_available(release.label());
tracing::info!(
- "new version v{} available at {}",
- release.version,
+ "new {} build available at {}",
+ release.channel.as_str(),
release.download_url
);
- if let Err(e) =
- crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
- {
+ 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: v{} available, waiting for explicit install",
- release.version
+ "auto-update check: {} available, waiting for explicit install",
+ release.label()
);
// Notify the TUI — blocking_send is safe from a std::thread
@@ -1640,7 +1881,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
};
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
- version: release.version.to_string(),
+ version: release.label().to_string(),
install_command: install_command.to_string(),
});
}
@@ -1792,8 +2033,13 @@ mod tests {
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(),
}
}
@@ -2071,8 +2317,13 @@ mod tests {
};
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 {
@@ -2305,8 +2556,13 @@ mod tests {
};
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 {
@@ -2435,8 +2691,13 @@ mod tests {
});
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(),
};
@@ -2722,6 +2983,67 @@ mod tests {
}
#[test]
+ fn stable_channel_installs_stable_asset_when_current_binary_is_preview() {
+ let older_stable = Version::parse("0.6.5").unwrap();
+ let preview_base = Version::parse("0.6.6").unwrap();
+ assert!(stable_channel_should_install(
+ &older_stable,
+ &preview_base,
+ true
+ ));
+ assert!(!stable_channel_should_install(
+ &older_stable,
+ &preview_base,
+ false
+ ));
+ }
+
+ #[test]
+ fn preview_manifest_reports_update_when_build_id_differs() {
+ let manifest: PreviewManifest = serde_json::from_str(
+ 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": {
+ "linux-x86_64": {
+ "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": {
+ "linux_x86_64": {
+ "url": "https://example.com/herdr-linux_x86_64",
+ "sha256": "deadbeef"
+ }
+ }
+ }
+ }
+ }"####,
+ )
+ .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");
@@ -2744,10 +3066,11 @@ mod tests {
"macos-x86_64",
"macos-aarch64",
] {
- let url = manifest
+ let url = &manifest
.assets
.get(target)
- .unwrap_or_else(|| panic!("missing asset URL for {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}"