From aa07e507c01d455a1f221d98b0a6ed3cad7dcfee Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 1 Jun 2026 15:16:04 +0300 Subject: [PATCH] fix: remove update star prompt --- docs/next/CHANGELOG.md | 3 + src/update.rs | 382 +---------------------------------------- 2 files changed, 5 insertions(+), 380 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 724604b6..0a8d2d8a 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -6,6 +6,9 @@ - Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148) - Added Kilo Code CLI automatic detection for idle, working, and blocked terminal states. (#270) +### Changed +- Removed the automatic GitHub star prompt from `herdr update`. + ## [0.6.6] - 2026-05-31 ### Added diff --git a/src/update.rs b/src/update.rs index ed421c89..c115efdb 100644 --- a/src/update.rs +++ b/src/update.rs @@ -12,10 +12,10 @@ use std::fs; use std::io::{self, BufRead, BufReader, IsTerminal, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Command; use std::time::{Duration, Instant}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer}; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json"; @@ -30,12 +30,6 @@ const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const SERVER_HANDOFF_REQUEST_TIMEOUT: Duration = Duration::from_secs(240); const SERVER_HANDOFF_CONFIRM_TIMEOUT: Duration = Duration::from_secs(30); const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); -const STAR_PROMPT_REPO: &str = "ogulcancelik/herdr"; -const STAR_PROMPT_STATE_FILE: &str = "github-star-prompt.json"; -const STAR_PROMPT_MAX_PROMPTS: u32 = 5; -const STAR_PROMPT_INTERVAL_UPDATES: [u32; 4] = [2, 3, 5, 7]; -const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); - fn fake_release_notes_body(version: &str) -> String { let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV) .ok() @@ -1527,292 +1521,6 @@ fn homebrew_cellar_keg_root(path: &Path) -> Option { Some(version_dir.to_path_buf()) } -// --------------------------------------------------------------------------- -// GitHub star prompt -// --------------------------------------------------------------------------- - -#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -struct StarPromptState { - #[serde(default)] - prompts_shown: u32, - #[serde(default)] - successful_updates_since_prompt: u32, - #[serde(default)] - known_starred: bool, -} - -#[derive(Debug, PartialEq, Eq)] -enum GithubStarStatus { - Starred, - NotStarred, - Unknown, -} - -fn star_prompt_state_path() -> PathBuf { - crate::config::state_dir().join(STAR_PROMPT_STATE_FILE) -} - -fn load_star_prompt_state_from_path(path: &Path) -> StarPromptState { - let Ok(content) = fs::read_to_string(path) else { - return StarPromptState::default(); - }; - serde_json::from_str(&content).unwrap_or_default() -} - -fn save_star_prompt_state_to_path(path: &Path, state: &StarPromptState) -> io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?; - let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id())); - fs::write(&tmp_path, json)?; - if let Err(err) = fs::rename(&tmp_path, path) { - let _ = fs::remove_file(&tmp_path); - return Err(err); - } - Ok(()) -} - -fn star_prompt_due(state: &StarPromptState) -> bool { - if state.known_starred { - return false; - } - if state.prompts_shown == 0 { - return true; - } - if state.prompts_shown >= STAR_PROMPT_MAX_PROMPTS { - return false; - } - - let interval_index = state.prompts_shown.saturating_sub(1) as usize; - let Some(interval) = STAR_PROMPT_INTERVAL_UPDATES.get(interval_index) else { - return false; - }; - state.successful_updates_since_prompt.saturating_add(1) >= *interval -} - -fn record_successful_update_without_star_prompt(state: &mut StarPromptState) { - state.successful_updates_since_prompt = state.successful_updates_since_prompt.saturating_add(1); -} - -fn record_star_prompt_shown(state: &mut StarPromptState) { - state.prompts_shown = state.prompts_shown.saturating_add(1); - state.successful_updates_since_prompt = 0; -} - -fn command_status_succeeds_with_timeout(command: &mut Command, timeout: Duration) -> bool { - let Ok(mut child) = command.spawn() else { - return false; - }; - let deadline = Instant::now() + timeout; - - loop { - match child.try_wait() { - Ok(Some(status)) => return status.success(), - Ok(None) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(50)); - } - Ok(None) => { - let _ = child.kill(); - let _ = child.wait(); - return false; - } - Err(_) => return false, - } - } -} - -fn command_output_with_timeout( - command: &mut Command, - timeout: Duration, -) -> io::Result> { - let mut child = command.spawn()?; - let deadline = Instant::now() + timeout; - - loop { - match child.try_wait()? { - Some(_) => return child.wait_with_output().map(Some), - None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), - None => { - let _ = child.kill(); - let _ = child.wait(); - return Ok(None); - } - } - } -} - -fn command_succeeds(command: &str, args: &[&str]) -> bool { - let mut command = Command::new(command); - command - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - command_status_succeeds_with_timeout(&mut command, GH_COMMAND_TIMEOUT) -} - -fn gh_cli_available() -> bool { - command_succeeds("gh", &["--version"]) -} - -fn gh_auth_succeeds() -> bool { - command_succeeds("gh", &["auth", "status", "--hostname", "github.com"]) -} - -fn gh_viewer_login() -> Option { - let mut command = Command::new("gh"); - command - .args(["api", "user", "--jq", ".login"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - - let Ok(Some(output)) = command_output_with_timeout(&mut command, GH_COMMAND_TIMEOUT) else { - return None; - }; - if !output.status.success() { - return None; - } - - let login = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (!login.is_empty()).then_some(login) -} - -fn gh_viewer_star_status() -> GithubStarStatus { - let mut command = Command::new("gh"); - command - .args([ - "repo", - "view", - STAR_PROMPT_REPO, - "--json", - "viewerHasStarred", - "--jq", - ".viewerHasStarred", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - - let Ok(Some(output)) = command_output_with_timeout(&mut command, GH_COMMAND_TIMEOUT) else { - return GithubStarStatus::Unknown; - }; - if !output.status.success() { - return GithubStarStatus::Unknown; - } - - match String::from_utf8_lossy(&output.stdout).trim() { - "true" => GithubStarStatus::Starred, - "false" => GithubStarStatus::NotStarred, - _ => GithubStarStatus::Unknown, - } -} - -fn parse_star_prompt_response(input: &str) -> Option { - match input.trim().to_ascii_lowercase().as_str() { - "" | "y" | "yes" => Some(true), - "n" | "no" => Some(false), - _ => None, - } -} - -fn star_prompt_message(viewer_login: &str) -> String { - format!("If herdr has been useful, star it using gh account {viewer_login}? [Y/n] ") -} - -fn prompt_to_star_repository(viewer_login: &str) -> io::Result { - let prompt = star_prompt_message(viewer_login); - loop { - eprint!("{prompt}"); - io::stderr().flush()?; - - let mut input = String::new(); - if io::stdin().read_line(&mut input)? == 0 { - return Ok(false); - } - if let Some(answer) = parse_star_prompt_response(&input) { - return Ok(answer); - } - eprintln!("please answer y or n."); - } -} - -fn star_repository_with_gh() -> bool { - let endpoint = format!("/user/starred/{STAR_PROMPT_REPO}"); - let mut command = Command::new("gh"); - command - .args(["api", "--method", "PUT", endpoint.as_str(), "--silent"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - if command_status_succeeds_with_timeout(&mut command, GH_COMMAND_TIMEOUT) { - eprintln!("starred {STAR_PROMPT_REPO}. thank you."); - true - } else { - false - } -} - -fn maybe_offer_star_after_successful_update() { - if !io::stdin().is_terminal() || !io::stderr().is_terminal() { - return; - } - - let path = star_prompt_state_path(); - let mut state = load_star_prompt_state_from_path(&path); - if state.known_starred { - return; - } - if !star_prompt_due(&state) { - record_successful_update_without_star_prompt(&mut state); - if let Err(err) = save_star_prompt_state_to_path(&path, &state) { - tracing::warn!(err = %err, "failed to save GitHub star prompt state"); - } - return; - } - - if !gh_cli_available() || !gh_auth_succeeds() { - return; - } - match gh_viewer_star_status() { - GithubStarStatus::NotStarred => {} - GithubStarStatus::Starred => { - state.known_starred = true; - if let Err(err) = save_star_prompt_state_to_path(&path, &state) { - tracing::warn!(err = %err, "failed to save GitHub star prompt state"); - } - return; - } - GithubStarStatus::Unknown => return, - } - - let Some(viewer_login) = gh_viewer_login() else { - return; - }; - - record_star_prompt_shown(&mut state); - if let Err(err) = save_star_prompt_state_to_path(&path, &state) { - tracing::warn!(err = %err, "failed to save GitHub star prompt state"); - return; - } - - match prompt_to_star_repository(&viewer_login) { - Ok(true) => { - if star_repository_with_gh() { - state.known_starred = true; - if let Err(err) = save_star_prompt_state_to_path(&path, &state) { - tracing::warn!(err = %err, "failed to save GitHub star prompt state"); - } - } - } - Ok(false) => {} - Err(err) => tracing::warn!(err = %err, "failed to read GitHub star prompt response"), - } -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -1867,8 +1575,6 @@ pub fn self_update(options: SelfUpdateOptions) -> Result { print_running_session_update_outcomes(&server_update_outcomes, &release); - maybe_offer_star_after_successful_update(); - Ok(release.version) } @@ -2828,90 +2534,6 @@ mod tests { assert!(v.major < 100); } - #[test] - fn star_prompt_response_defaults_yes() { - assert_eq!(parse_star_prompt_response(""), Some(true)); - assert_eq!(parse_star_prompt_response("\n"), Some(true)); - assert_eq!(parse_star_prompt_response("y"), Some(true)); - assert_eq!(parse_star_prompt_response("yes"), Some(true)); - assert_eq!(parse_star_prompt_response("n"), Some(false)); - assert_eq!(parse_star_prompt_response("no"), Some(false)); - assert_eq!(parse_star_prompt_response("later"), None); - } - - #[test] - fn star_prompt_mentions_gh_account_when_known() { - assert_eq!( - star_prompt_message("ogulcancelik"), - "If herdr has been useful, star it using gh account ogulcancelik? [Y/n] " - ); - } - - #[test] - fn star_prompt_is_due_five_times_with_increasing_intervals() { - let mut state = StarPromptState::default(); - - assert!(star_prompt_due(&state)); - record_star_prompt_shown(&mut state); - assert_eq!(state.prompts_shown, 1); - assert_eq!(state.successful_updates_since_prompt, 0); - - for interval in STAR_PROMPT_INTERVAL_UPDATES { - for update in 1..interval { - assert!( - !star_prompt_due(&state), - "prompt was early at update {update}/{interval}" - ); - record_successful_update_without_star_prompt(&mut state); - } - - assert!( - star_prompt_due(&state), - "prompt was not due after {interval} updates" - ); - record_star_prompt_shown(&mut state); - assert_eq!(state.successful_updates_since_prompt, 0); - } - - assert_eq!(state.prompts_shown, STAR_PROMPT_MAX_PROMPTS); - for _ in 0..20 { - assert!(!star_prompt_due(&state)); - record_successful_update_without_star_prompt(&mut state); - } - } - - #[test] - fn star_prompt_is_never_due_after_known_starred() { - let mut state = StarPromptState { - prompts_shown: 1, - successful_updates_since_prompt: 99, - known_starred: true, - }; - - assert!(!star_prompt_due(&state)); - record_successful_update_without_star_prompt(&mut state); - assert!(!star_prompt_due(&state)); - } - - #[test] - fn star_prompt_state_round_trips() { - let path = std::env::temp_dir().join(format!( - "herdr-star-prompt-{}-{}.json", - std::process::id(), - "round-trip" - )); - let state = StarPromptState { - prompts_shown: 2, - successful_updates_since_prompt: 1, - known_starred: false, - }; - - save_star_prompt_state_to_path(&path, &state).unwrap(); - - assert_eq!(load_star_prompt_state_from_path(&path), state); - let _ = fs::remove_file(path); - } - #[test] fn platform_target_is_known() { let (os, arch) = platform_target();