fix: remove update star prompt
This commit is contained in:
parent
ab649adfde
commit
aa07e507c0
|
|
@ -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
|
||||
|
|
|
|||
382
src/update.rs
382
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<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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue