Skip to main content

navi_core/
update.rs

1//! NAVI self-update: check GitHub Releases and apply via the official installer.
2//!
3//! Modern frictionless path:
4//! 1. `check_for_update` → compare current `CARGO_PKG_VERSION` to latest release tag
5//! 2. TUI / SDK surfaces the result
6//! 3. `apply_update` re-runs `install.sh` (or install.ps1 on Windows) pinned to that version
7
8use 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/// Information about an available NAVI release that is newer than the running binary.
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19pub struct UpdateInfo {
20    /// Currently running version (semver without leading `v`).
21    pub current_version: String,
22    /// Latest GitHub release tag (may include leading `v`).
23    pub latest_tag: String,
24    /// Latest version normalized without leading `v`.
25    pub latest_version: String,
26    /// HTML URL of the release page.
27    pub release_url: String,
28    /// Release body / notes when available.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub body: Option<String>,
31    /// Whether this is a prerelease.
32    #[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
54/// Running binary version (from the crate that embeds this code at build time
55/// for the CLI; callers may override with an explicit current version).
56pub fn current_version() -> &'static str {
57    env!("CARGO_PKG_VERSION")
58}
59
60/// Normalize a tag or version string to bare semver (`v0.2.3` → `0.2.3`).
61pub fn normalize_version(v: &str) -> String {
62    v.trim().trim_start_matches('v').trim().to_string()
63}
64
65/// Compare two bare semver strings. Returns true if `candidate` is strictly greater.
66pub 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
81/// Check GitHub Releases for a newer version of NAVI.
82///
83/// Returns `Ok(None)` when already up to date (or latest is a draft/prerelease
84/// older/equal). Network failures return `Err`.
85pub 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, &current_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
153/// Apply an update by re-running the official installer for `info.latest_version`.
154///
155/// Spawns the platform installer and waits for completion. On success the
156/// new binary is on disk; the running process should exit so the user restarts.
157///
158/// **Important:** the installer process does **not** inherit the parent
159/// stdout/stderr. That would paint raw ANSI progress over an active TUI
160/// alternate screen. Output is captured and only attached to errors.
161pub 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
169/// Run a child process with piped stdio so TUI/alternate-screen sessions are
170/// not corrupted by installer progress ANSI. Captured output is kept for errors.
171fn run_silent(mut cmd: std::process::Command) -> Result<()> {
172    use std::process::Stdio;
173
174    // Discourage colored progress from install.sh / powershell host noise.
175    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        // Success path: discard installer chatter (TUI already shows its own toast).
187        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    // Strip ANSI so error notifications stay readable in the TUI.
215    let plain = strip_ansi(&combined);
216    // Keep last ~1.5 KiB of meaningful lines.
217    let trimmed = plain.trim();
218    if trimmed.len() <= 1500 {
219        return trimmed.to_string();
220    }
221    let start = trimmed.len().saturating_sub(1500);
222    // Prefer cutting on a newline boundary.
223    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    // Minimal CSI / OSC stripper for installer progress codes (no dependency).
232    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                // CSI: ESC [ ... final byte @-~
242                for next in chars.by_ref() {
243                    if ('\u{40}'..='\u{7e}').contains(&next) {
244                        break;
245                    }
246                }
247            }
248            Some(']') => {
249                // OSC: ESC ] ... BEL or ST (ESC \)
250                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            // Download install.ps1 and run with -Version
271            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            // Fallback: curl-style via iwr to temp
285            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            // curl | sh with pinned version (checksum verified by install.sh).
311            // Stdio is piped (not inherited) so an active TUI is not corrupted.
312            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        // A tiny failing command must not write to our inherited stdout.
363        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}