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://raw.githubusercontent.com/navi-ai-org/navi/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.
157pub async fn apply_update(info: &UpdateInfo) -> Result<()> {
158    let version = info.latest_version.clone();
159    tokio::task::spawn_blocking(move || apply_update_blocking(&version))
160        .await
161        .context("update task join")??;
162    Ok(())
163}
164
165fn apply_update_blocking(version: &str) -> Result<()> {
166    let version = normalize_version(version);
167    match std::env::consts::OS {
168        "windows" => {
169            // Download install.ps1 and run with -Version
170            let status = std::process::Command::new("powershell")
171                .args([
172                    "-NoProfile",
173                    "-ExecutionPolicy",
174                    "Bypass",
175                    "-Command",
176                    &format!(
177                        "irm {INSTALL_PS1} | iex; if (Get-Command Install-Navi -ErrorAction SilentlyContinue) {{ Install-Navi -Version {version} }} else {{ & ([scriptblock]::Create((irm {INSTALL_PS1}))) -Version {version} }}"
178                    ),
179                ])
180                .status()
181                .context("spawn powershell installer")?;
182            if !status.success() {
183                // Fallback: curl-style via iwr to temp
184                let tmp = std::env::temp_dir().join("navi-install.ps1");
185                let script = std::process::Command::new("powershell")
186                    .args([
187                        "-NoProfile",
188                        "-Command",
189                        &format!(
190                            "Invoke-WebRequest -Uri '{INSTALL_PS1}' -OutFile '{}'",
191                            tmp.display()
192                        ),
193                    ])
194                    .status()
195                    .context("download install.ps1")?;
196                if !script.success() {
197                    anyhow::bail!("failed to download install.ps1");
198                }
199                let status = std::process::Command::new("powershell")
200                    .args([
201                        "-NoProfile",
202                        "-ExecutionPolicy",
203                        "Bypass",
204                        "-File",
205                        tmp.to_str().unwrap_or("navi-install.ps1"),
206                        "-Version",
207                        &version,
208                    ])
209                    .status()
210                    .context("run install.ps1")?;
211                if !status.success() {
212                    anyhow::bail!("install.ps1 exited with {status}");
213                }
214            }
215            Ok(())
216        }
217        _ => {
218            // curl | sh with pinned version (checksum verified by install.sh)
219            let status = std::process::Command::new("sh")
220                .args([
221                    "-c",
222                    &format!("curl -fsSL {INSTALL_SH} | sh -s -- --version {version}"),
223                ])
224                .status()
225                .context("spawn install.sh")?;
226            if !status.success() {
227                anyhow::bail!("install.sh exited with {status}");
228            }
229            Ok(())
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn normalize_strips_v() {
240        assert_eq!(normalize_version("v0.2.3"), "0.2.3");
241        assert_eq!(normalize_version("0.2.3"), "0.2.3");
242    }
243
244    #[test]
245    fn semver_compare() {
246        assert!(version_is_newer("0.2.3", "0.2.2"));
247        assert!(version_is_newer("1.0.0", "0.9.9"));
248        assert!(!version_is_newer("0.2.2", "0.2.3"));
249        assert!(!version_is_newer("0.2.3", "0.2.3"));
250    }
251}