fix: remove update star prompt

This commit is contained in:
Ogulcan Celik 2026-06-01 15:16:04 +03:00
parent ab649adfde
commit aa07e507c0
2 changed files with 5 additions and 380 deletions

View File

@ -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

View File

@ -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<PathBuf> {
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<Option<std::process::Output>> {
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<String> {
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<bool> {
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<bool> {
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<Version, String> {
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();