diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:48:02 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:48:02 +0300 |
| commit | b751d102f158968aab681cb663d27b4c32197f62 (patch) | |
| tree | 85d03609314559df7705c2cb0ab4e37ab6d9715f /src | |
| parent | 295b09ca25edbccac65401ee84fa420dd3888aad (diff) | |
feat: add preview update channel
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/server.rs | 2 | ||||
| -rw-r--r-- | src/build_info.rs | 44 | ||||
| -rw-r--r-- | src/checksum.rs | 67 | ||||
| -rw-r--r-- | src/cli.rs | 104 | ||||
| -rw-r--r-- | src/cli/status.rs | 22 | ||||
| -rw-r--r-- | src/config.rs | 2 | ||||
| -rw-r--r-- | src/config/io.rs | 16 | ||||
| -rw-r--r-- | src/config/model.rs | 46 | ||||
| -rw-r--r-- | src/main.rs | 20 | ||||
| -rw-r--r-- | src/product_announcements.rs | 4 | ||||
| -rw-r--r-- | src/release_notes.rs | 4 | ||||
| -rw-r--r-- | src/remote.rs | 276 | ||||
| -rw-r--r-- | src/server/autodetect.rs | 2 | ||||
| -rw-r--r-- | src/server/handoff.rs | 6 | ||||
| -rw-r--r-- | src/update.rs | 419 |
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()); + } +} @@ -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(¤t_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(¤t_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(¤t_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(¤t_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, ¤t, 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}" |
