1use anyhow::{Context, Result};
9use serde::Deserialize;
10
11const DEFAULT_REPO: &str = "navi-ai-org/navi";
12const INSTALL_SH: &str =
13 "https://github.com/navi-ai-org/navi/raw/refs/heads/main/scripts/install.sh";
14const INSTALL_PS1: &str =
15 "https://raw.githubusercontent.com/navi-ai-org/navi/main/scripts/install.ps1";
16
17#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19pub struct UpdateInfo {
20 pub current_version: String,
22 pub latest_tag: String,
24 pub latest_version: String,
26 pub release_url: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub body: Option<String>,
31 #[serde(default)]
33 pub prerelease: bool,
34}
35
36impl UpdateInfo {
37 pub fn is_newer(&self) -> bool {
38 version_is_newer(&self.latest_version, &self.current_version)
39 }
40}
41
42#[derive(Debug, Deserialize)]
43struct GhRelease {
44 tag_name: String,
45 html_url: String,
46 #[serde(default)]
47 body: Option<String>,
48 #[serde(default)]
49 prerelease: bool,
50 #[serde(default)]
51 draft: bool,
52}
53
54pub fn current_version() -> &'static str {
57 env!("CARGO_PKG_VERSION")
58}
59
60pub fn normalize_version(v: &str) -> String {
62 v.trim().trim_start_matches('v').trim().to_string()
63}
64
65pub fn version_is_newer(candidate: &str, current: &str) -> bool {
67 let c = parse_semver(candidate);
68 let cur = parse_semver(current);
69 c > cur
70}
71
72fn parse_semver(v: &str) -> (u64, u64, u64) {
73 let v = normalize_version(v);
74 let mut parts = v.split(|c| c == '.' || c == '-' || c == '+');
75 let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
76 let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
77 let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
78 (major, minor, patch)
79}
80
81pub async fn check_for_update(
86 current: &str,
87 repo: Option<&str>,
88 include_prerelease: bool,
89) -> Result<Option<UpdateInfo>> {
90 let repo = repo.unwrap_or(DEFAULT_REPO);
91 let current_version = normalize_version(current);
92 let url = if include_prerelease {
93 format!("https://api.github.com/repos/{repo}/releases?per_page=5")
94 } else {
95 format!("https://api.github.com/repos/{repo}/releases/latest")
96 };
97
98 let client = reqwest::Client::builder()
99 .timeout(std::time::Duration::from_secs(15))
100 .user_agent(format!("navi/{current_version}"))
101 .build()
102 .context("build HTTP client for update check")?;
103
104 let release = if include_prerelease {
105 let list: Vec<GhRelease> = client
106 .get(&url)
107 .send()
108 .await
109 .context("fetch releases list")?
110 .error_for_status()
111 .context("releases list HTTP error")?
112 .json()
113 .await
114 .context("parse releases list")?;
115 list.into_iter()
116 .find(|r| !r.draft && (include_prerelease || !r.prerelease))
117 .context("no suitable release found")?
118 } else {
119 client
120 .get(&url)
121 .send()
122 .await
123 .context("fetch latest release")?
124 .error_for_status()
125 .context("latest release HTTP error")?
126 .json::<GhRelease>()
127 .await
128 .context("parse latest release")?
129 };
130
131 if release.draft {
132 return Ok(None);
133 }
134 if release.prerelease && !include_prerelease {
135 return Ok(None);
136 }
137
138 let latest_version = normalize_version(&release.tag_name);
139 if !version_is_newer(&latest_version, ¤t_version) {
140 return Ok(None);
141 }
142
143 Ok(Some(UpdateInfo {
144 current_version,
145 latest_tag: release.tag_name,
146 latest_version,
147 release_url: release.html_url,
148 body: release.body.filter(|b| !b.trim().is_empty()),
149 prerelease: release.prerelease,
150 }))
151}
152
153pub async fn apply_update(info: &UpdateInfo) -> Result<()> {
162 let version = info.latest_version.clone();
163 tokio::task::spawn_blocking(move || apply_update_blocking(&version))
164 .await
165 .context("update task join")??;
166 Ok(())
167}
168
169fn run_silent(mut cmd: std::process::Command) -> Result<()> {
172 use std::process::Stdio;
173
174 cmd.env("NO_COLOR", "1");
176 cmd.env("TERM", "dumb");
177 cmd.stdin(Stdio::null())
178 .stdout(Stdio::piped())
179 .stderr(Stdio::piped());
180
181 let output = cmd
182 .output()
183 .with_context(|| format!("spawn installer: {:?}", cmd.get_program()))?;
184
185 if output.status.success() {
186 if !output.stdout.is_empty() {
188 tracing::debug!(bytes = output.stdout.len(), "installer stdout (suppressed)");
189 }
190 if !output.stderr.is_empty() {
191 tracing::debug!(bytes = output.stderr.len(), "installer stderr (suppressed)");
192 }
193 return Ok(());
194 }
195
196 let tail = installer_error_tail(&output.stdout, &output.stderr);
197 if tail.is_empty() {
198 anyhow::bail!("installer exited with {}", output.status);
199 }
200 anyhow::bail!("installer exited with {}: {}", output.status, tail);
201}
202
203fn installer_error_tail(stdout: &[u8], stderr: &[u8]) -> String {
204 let mut combined = String::new();
205 if !stderr.is_empty() {
206 combined.push_str(&String::from_utf8_lossy(stderr));
207 }
208 if !stdout.is_empty() {
209 if !combined.is_empty() {
210 combined.push('\n');
211 }
212 combined.push_str(&String::from_utf8_lossy(stdout));
213 }
214 let plain = strip_ansi(&combined);
216 let trimmed = plain.trim();
218 if trimmed.len() <= 1500 {
219 return trimmed.to_string();
220 }
221 let start = trimmed.len().saturating_sub(1500);
222 let slice = &trimmed[start..];
224 match slice.find('\n') {
225 Some(i) => slice[i + 1..].trim().to_string(),
226 None => slice.trim().to_string(),
227 }
228}
229
230fn strip_ansi(s: &str) -> String {
231 let mut out = String::with_capacity(s.len());
233 let mut chars = s.chars().peekable();
234 while let Some(c) = chars.next() {
235 if c != '\u{1b}' {
236 out.push(c);
237 continue;
238 }
239 match chars.next() {
240 Some('[') => {
241 for next in chars.by_ref() {
243 if ('\u{40}'..='\u{7e}').contains(&next) {
244 break;
245 }
246 }
247 }
248 Some(']') => {
249 while let Some(next) = chars.next() {
251 if next == '\u{07}' {
252 break;
253 }
254 if next == '\u{1b}' && matches!(chars.peek(), Some('\\')) {
255 let _ = chars.next();
256 break;
257 }
258 }
259 }
260 Some(_) | None => {}
261 }
262 }
263 out
264}
265
266fn apply_update_blocking(version: &str) -> Result<()> {
267 let version = normalize_version(version);
268 match std::env::consts::OS {
269 "windows" => {
270 let mut primary = std::process::Command::new("powershell");
272 primary.args([
273 "-NoProfile",
274 "-ExecutionPolicy",
275 "Bypass",
276 "-Command",
277 &format!(
278 "irm {INSTALL_PS1} | iex; if (Get-Command Install-Navi -ErrorAction SilentlyContinue) {{ Install-Navi -Version {version} }} else {{ & ([scriptblock]::Create((irm {INSTALL_PS1}))) -Version {version} }}"
279 ),
280 ]);
281 if run_silent(primary).is_ok() {
282 return Ok(());
283 }
284 let tmp = std::env::temp_dir().join("navi-install.ps1");
286 let mut download = std::process::Command::new("powershell");
287 download.args([
288 "-NoProfile",
289 "-Command",
290 &format!(
291 "Invoke-WebRequest -Uri '{INSTALL_PS1}' -OutFile '{}'",
292 tmp.display()
293 ),
294 ]);
295 run_silent(download).context("download install.ps1")?;
296 let mut install = std::process::Command::new("powershell");
297 install.args([
298 "-NoProfile",
299 "-ExecutionPolicy",
300 "Bypass",
301 "-File",
302 tmp.to_str().unwrap_or("navi-install.ps1"),
303 "-Version",
304 &version,
305 ]);
306 run_silent(install).context("run install.ps1")?;
307 Ok(())
308 }
309 _ => {
310 let mut cmd = std::process::Command::new("sh");
313 cmd.args([
314 "-c",
315 &format!("curl -fsSL {INSTALL_SH} | sh -s -- --version {version}"),
316 ]);
317 run_silent(cmd).context("run install.sh")?;
318 Ok(())
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn normalize_strips_v() {
329 assert_eq!(normalize_version("v0.2.3"), "0.2.3");
330 assert_eq!(normalize_version("0.2.3"), "0.2.3");
331 }
332
333 #[test]
334 fn semver_compare() {
335 assert!(version_is_newer("0.2.3", "0.2.2"));
336 assert!(version_is_newer("1.0.0", "0.9.9"));
337 assert!(!version_is_newer("0.2.2", "0.2.3"));
338 assert!(!version_is_newer("0.2.3", "0.2.3"));
339 }
340
341 #[test]
342 fn strip_ansi_removes_csi_sequences() {
343 let raw = "\x1b[1mlinux-x64\x1b[0m installed to \x1b[1m/home/enrell/.local/bin/navi\x1b[0m";
344 assert_eq!(
345 strip_ansi(raw),
346 "linux-x64 installed to /home/enrell/.local/bin/navi"
347 );
348 }
349
350 #[test]
351 fn installer_error_tail_prefers_stderr_and_strips_ansi() {
352 let stderr = b"\x1b[0;31m[navi]\x1b[0m boom\n";
353 let stdout = b"progress line\n";
354 let tail = installer_error_tail(stdout, stderr);
355 assert!(tail.contains("[navi] boom"));
356 assert!(tail.contains("progress line"));
357 assert!(!tail.contains('\u{1b}'));
358 }
359
360 #[test]
361 fn run_silent_does_not_inherit_stdio_and_captures_failure() {
362 let mut cmd = std::process::Command::new("sh");
364 cmd.args(["-c", "printf '\\033[1mFAIL\\033[0m\\n' >&2; exit 7"]);
365 let err = run_silent(cmd).expect_err("expected failure");
366 let msg = format!("{err:#}");
367 assert!(msg.contains("exit"), "{msg}");
368 assert!(msg.contains("FAIL"), "{msg}");
369 assert!(!msg.contains('\u{1b}'), "{msg}");
370 }
371}