Skip to main content

innate_core/
upgrade.rs

1//! `innate upgrade` — self-update from GitHub Releases.
2//!
3//! Downloads the pre-built binary for the current platform, verifies its
4//! SHA-256 checksum, atomically replaces the running executable, then
5//! optionally runs `innate migrate` to bring the database schema up to date.
6
7use std::io::Read;
8use std::path::Path;
9
10use anyhow::{bail, Context, Result};
11use sha2::{Digest, Sha256};
12
13const REPO: &str = "vima-tech/Innate";
14const GITHUB_API: &str = "https://api.github.com";
15
16// ---------------------------------------------------------------------------
17// Public entry point
18// ---------------------------------------------------------------------------
19
20pub fn run_upgrade(version: Option<&str>, db_path: &Path, check_only: bool) -> Result<()> {
21    let current_ver = env!("CARGO_PKG_VERSION");
22    let target = current_target();
23
24    if target == "unknown" {
25        bail!(
26            "Unsupported platform for auto-upgrade. \
27             Build from source: cargo build --release --manifest-path core/Cargo.toml"
28        );
29    }
30
31    // Resolve the version to install.
32    let target_ver = match version {
33        Some(v) => v.trim_start_matches('v').to_string(),
34        None => latest_version()?,
35    };
36
37    if target_ver == current_ver {
38        println!("innate {current_ver} is already up to date.");
39        return Ok(());
40    }
41
42    println!("innate {current_ver}  →  {target_ver}");
43
44    if check_only {
45        println!("Run `innate upgrade` (without --check) to install.");
46        return Ok(());
47    }
48
49    let ext = if cfg!(target_os = "windows") {
50        ".exe"
51    } else {
52        ""
53    };
54    let asset = format!("innate-{target}{ext}");
55    let base = format!("https://github.com/{REPO}/releases/download/v{target_ver}");
56
57    // 1. Fetch checksum.
58    let sha_text = http_get_text(&format!("{base}/{asset}.sha256"))
59        .with_context(|| format!("Could not fetch checksum for {asset} v{target_ver}"))?;
60    let expected_sha = sha_text
61        .split_whitespace()
62        .next()
63        .context("SHA-256 file is empty")?
64        .to_lowercase();
65
66    // 2. Download binary.
67    println!("  Downloading {asset}…");
68    let bytes = http_get_bytes(&format!("{base}/{asset}"))
69        .with_context(|| format!("Could not download {asset}"))?;
70
71    // 3. Verify checksum.
72    let actual_sha = crate::utils::hex(&Sha256::digest(&bytes));
73    if actual_sha != expected_sha {
74        bail!(
75            "SHA-256 mismatch — download may be corrupted.\n  \
76             expected: {expected_sha}\n  \
77             got:      {actual_sha}"
78        );
79    }
80
81    // 4. Write to a temp file next to the running exe.
82    let exe = std::env::current_exe().context("Cannot resolve current executable path")?;
83    let tmp = exe.with_extension("upgrade.tmp");
84    std::fs::write(&tmp, &bytes).context("Failed to write temporary binary")?;
85
86    #[cfg(unix)]
87    {
88        use std::os::unix::fs::PermissionsExt;
89        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
90            .context("Failed to mark binary as executable")?;
91    }
92
93    // 5. Atomically replace running exe.
94    //    On Windows the running exe is locked; save the old one as .old.exe first.
95    #[cfg(target_os = "windows")]
96    {
97        let old = exe.with_extension("old.exe");
98        std::fs::rename(&exe, &old).context("Failed to move old binary")?;
99        if let Err(e) = std::fs::rename(&tmp, &exe) {
100            // Roll back: restore the original so the user isn't left without a binary.
101            let _ = std::fs::rename(&old, &exe);
102            return Err(e).context("Failed to install new binary (rolled back)");
103        }
104        println!("  Previous binary saved as {}", old.display());
105    }
106    #[cfg(not(target_os = "windows"))]
107    {
108        std::fs::rename(&tmp, &exe)
109            .context("Failed to replace binary — try: sudo innate upgrade")?;
110    }
111
112    println!("✓ innate {target_ver} installed.");
113
114    // 6. Run schema migration if a db path is usable.
115    if db_path.exists() {
116        println!("  Migrating schema…");
117        match crate::migrate::run_migrations(db_path) {
118            Ok(applied) if applied.is_empty() => println!("  Schema already at {target_ver}."),
119            Ok(applied) => {
120                for step in &applied {
121                    println!("    applied: {step}");
122                }
123                println!("  Migration complete.");
124            }
125            Err(e) => eprintln!("  Migration warning (run `innate migrate` manually): {e}"),
126        }
127    }
128
129    Ok(())
130}
131
132// ---------------------------------------------------------------------------
133// GitHub helpers
134// ---------------------------------------------------------------------------
135
136fn latest_version() -> Result<String> {
137    let url = format!("{GITHUB_API}/repos/{REPO}/releases/latest");
138    let text = http_get_text(&url).context("Failed to fetch latest release info from GitHub")?;
139    let json: serde_json::Value = serde_json::from_str(&text)?;
140    json["tag_name"]
141        .as_str()
142        .map(|t| t.trim_start_matches('v').to_string())
143        .context("GitHub response missing tag_name — try specifying a version: innate upgrade --version 0.1.8")
144}
145
146fn http_get_text(url: &str) -> Result<String> {
147    let mut resp = ureq::get(url)
148        .header(
149            "User-Agent",
150            &format!("innate/{}", env!("CARGO_PKG_VERSION")),
151        )
152        .header("Accept", "application/vnd.github+json")
153        .call()
154        .with_context(|| format!("HTTP GET {url}"))?;
155    Ok(resp.body_mut().read_to_string()?)
156}
157
158fn http_get_bytes(url: &str) -> Result<Vec<u8>> {
159    let mut resp = ureq::get(url)
160        .header(
161            "User-Agent",
162            &format!("innate/{}", env!("CARGO_PKG_VERSION")),
163        )
164        .call()
165        .with_context(|| format!("HTTP GET {url}"))?;
166    let mut buf = Vec::new();
167    // `as_reader()` streams without ureq's default read-to-vec size cap — release
168    // binaries are multi-MB.
169    resp.body_mut().as_reader().read_to_end(&mut buf)?;
170    Ok(buf)
171}
172
173// ---------------------------------------------------------------------------
174// Platform detection
175// ---------------------------------------------------------------------------
176
177fn current_target() -> &'static str {
178    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
179    return "x86_64-unknown-linux-musl";
180    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
181    return "aarch64-unknown-linux-musl";
182    #[cfg(all(target_os = "linux", target_arch = "arm"))]
183    return "armv7-unknown-linux-musleabihf";
184    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
185    return "x86_64-apple-darwin";
186    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
187    return "aarch64-apple-darwin";
188    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
189    return "x86_64-pc-windows-msvc";
190    #[allow(unreachable_code)]
191    "unknown"
192}