Skip to main content

mvm_cli/
upgrade.rs

1use anyhow::{Context, Result};
2use std::path::Path;
3
4use crate::http;
5use crate::ui;
6use mvm_runtime::shell::run_host;
7
8const GITHUB_REPO: &str = "auser/mvm";
9
10/// Current version compiled into the binary (from Cargo.toml).
11fn current_version() -> &'static str {
12    env!("CARGO_PKG_VERSION")
13}
14
15/// Detect the target triple for the current platform at compile time.
16/// Returns strings matching the release artifact naming from release.yml.
17fn detect_target() -> Result<&'static str> {
18    #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
19    return Ok("aarch64-apple-darwin");
20
21    #[cfg(all(target_arch = "x86_64", target_os = "macos"))]
22    return Ok("x86_64-apple-darwin");
23
24    #[cfg(all(target_arch = "x86_64", target_os = "linux"))]
25    return Ok("x86_64-unknown-linux-gnu");
26
27    #[cfg(all(target_arch = "aarch64", target_os = "linux"))]
28    return Ok("aarch64-unknown-linux-gnu");
29
30    #[cfg(not(any(
31        all(target_arch = "aarch64", target_os = "macos"),
32        all(target_arch = "x86_64", target_os = "macos"),
33        all(target_arch = "x86_64", target_os = "linux"),
34        all(target_arch = "aarch64", target_os = "linux"),
35    )))]
36    anyhow::bail!(
37        "Unsupported platform: {} / {}",
38        std::env::consts::ARCH,
39        std::env::consts::OS
40    );
41}
42
43/// Query the GitHub releases API for the latest release tag name.
44fn fetch_latest_version() -> Result<String> {
45    let url = format!(
46        "https://api.github.com/repos/{}/releases/latest",
47        GITHUB_REPO
48    );
49
50    let json = http::fetch_json(&url)
51        .context("Failed to query GitHub releases API. Check your network connection.")?;
52
53    let tag = json["tag_name"]
54        .as_str()
55        .context("GitHub API response missing 'tag_name' field")?;
56
57    Ok(tag.to_string())
58}
59
60/// Strip the "v" prefix from a version tag.
61fn strip_v_prefix(tag: &str) -> &str {
62    tag.strip_prefix('v').unwrap_or(tag)
63}
64
65/// Download the release archive into the given temp directory.
66fn download_release(version: &str, target: &str, tmp_dir: &Path) -> Result<()> {
67    let archive_name = format!("mvm-{}.tar.gz", target);
68    let download_url = format!(
69        "https://github.com/{}/releases/download/{}/{}",
70        GITHUB_REPO, version, archive_name
71    );
72    let dest = tmp_dir.join(&archive_name);
73
74    let sp = ui::spinner(&format!("Downloading {}...", download_url));
75
76    http::download_file(&download_url, &dest).with_context(|| {
77        format!(
78            "Download failed. Check that {} has a release for {}.",
79            version, target
80        )
81    })?;
82
83    sp.finish_and_clear();
84    ui::success("Download complete.");
85    Ok(())
86}
87
88/// Check if a directory is writable by the current user.
89fn is_writable(path: &Path) -> bool {
90    tempfile::Builder::new()
91        .prefix(".mvm-write-test-")
92        .tempfile_in(path)
93        .is_ok()
94}
95
96/// Extract the archive and install the binary + resources, replacing the current installation.
97fn extract_and_install(target: &str, tmp_dir: &Path, current_exe: &Path) -> Result<()> {
98    let archive_name = format!("mvm-{}.tar.gz", target);
99    let archive_path = tmp_dir.join(&archive_name);
100
101    let output = run_host(
102        "tar",
103        &[
104            "xzf",
105            archive_path.to_str().unwrap(),
106            "-C",
107            tmp_dir.to_str().unwrap(),
108        ],
109    )?;
110
111    if !output.status.success() {
112        anyhow::bail!("Failed to extract archive");
113    }
114
115    let extracted_dir = tmp_dir.join(format!("mvm-{}", target));
116    let new_binary = extracted_dir.join("mvm");
117    if !new_binary.exists() {
118        anyhow::bail!(
119            "Binary not found in archive at expected path: mvm-{}/mvm",
120            target
121        );
122    }
123
124    let install_dir = current_exe
125        .parent()
126        .context("Cannot determine install directory")?;
127
128    let needs_sudo = !is_writable(install_dir);
129
130    ui::info(&format!("Installing to {}...", install_dir.display()));
131    if needs_sudo {
132        ui::warn("Requires elevated permissions.");
133    }
134
135    // --- Replace binary ---
136    let backup_path = current_exe.with_extension("old");
137
138    if needs_sudo {
139        run_sudo_mv(current_exe, &backup_path)?;
140        if let Err(e) = run_sudo_cp(&new_binary, current_exe) {
141            let _ = run_sudo_mv(&backup_path, current_exe);
142            return Err(e);
143        }
144        let _ = run_host("sudo", &["chmod", "+x", current_exe.to_str().unwrap()]);
145        let _ = run_host("sudo", &["rm", "-f", backup_path.to_str().unwrap()]);
146    } else {
147        std::fs::rename(current_exe, &backup_path).context("Failed to back up current binary")?;
148        if let Err(e) = std::fs::copy(&new_binary, current_exe) {
149            let _ = std::fs::rename(&backup_path, current_exe);
150            return Err(anyhow::anyhow!(e).context("Failed to install new binary"));
151        }
152        set_executable(current_exe)?;
153        let _ = std::fs::remove_file(&backup_path);
154    }
155
156    // --- Replace resources ---
157    let new_resources = extracted_dir.join("resources");
158    if new_resources.exists() {
159        let dest_resources = install_dir.join("resources");
160        ui::info("Updating resources...");
161
162        if needs_sudo {
163            let _ = run_host("sudo", &["rm", "-rf", dest_resources.to_str().unwrap()]);
164            let output = run_host(
165                "sudo",
166                &[
167                    "cp",
168                    "-r",
169                    new_resources.to_str().unwrap(),
170                    dest_resources.to_str().unwrap(),
171                ],
172            )?;
173            if !output.status.success() {
174                ui::warn("Failed to update resources directory");
175            }
176        } else {
177            let _ = std::fs::remove_dir_all(&dest_resources);
178            copy_dir_recursive(&new_resources, &dest_resources)
179                .context("Failed to update resources directory")?;
180        }
181    }
182
183    Ok(())
184}
185
186fn run_sudo_mv(from: &Path, to: &Path) -> Result<()> {
187    let output = run_host(
188        "sudo",
189        &["mv", from.to_str().unwrap(), to.to_str().unwrap()],
190    )?;
191    if !output.status.success() {
192        anyhow::bail!("sudo mv failed");
193    }
194    Ok(())
195}
196
197fn run_sudo_cp(from: &Path, to: &Path) -> Result<()> {
198    let output = run_host(
199        "sudo",
200        &["cp", from.to_str().unwrap(), to.to_str().unwrap()],
201    )?;
202    if !output.status.success() {
203        anyhow::bail!("sudo cp failed");
204    }
205    Ok(())
206}
207
208#[cfg(unix)]
209fn set_executable(path: &Path) -> Result<()> {
210    use std::os::unix::fs::PermissionsExt;
211    let mut perms = std::fs::metadata(path)?.permissions();
212    perms.set_mode(0o755);
213    std::fs::set_permissions(path, perms)?;
214    Ok(())
215}
216
217#[cfg(not(unix))]
218fn set_executable(_path: &Path) -> Result<()> {
219    Ok(())
220}
221
222/// Recursively copy a directory.
223fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
224    std::fs::create_dir_all(dst)?;
225    for entry in std::fs::read_dir(src)? {
226        let entry = entry?;
227        let ty = entry.file_type()?;
228        let dest_path = dst.join(entry.file_name());
229        if ty.is_dir() {
230            copy_dir_recursive(&entry.path(), &dest_path)?;
231        } else {
232            std::fs::copy(entry.path(), &dest_path)?;
233        }
234    }
235    Ok(())
236}
237
238/// Main entry point: check for updates and optionally install.
239pub fn upgrade(check_only: bool, force: bool) -> Result<()> {
240    let current = current_version();
241    ui::info(&format!("Current version: {}", current));
242
243    let sp = ui::spinner("Checking for updates...");
244    let latest_tag = fetch_latest_version()?;
245    let latest_version = strip_v_prefix(&latest_tag);
246    sp.finish_and_clear();
247
248    if latest_version == current && !force {
249        ui::success(&format!("Already up to date ({}).", current));
250        return Ok(());
251    }
252
253    if latest_version == current {
254        ui::info(&format!(
255            "Already at {} but --force specified, reinstalling.",
256            current
257        ));
258    } else {
259        ui::info(&format!(
260            "New version available: {} -> {}",
261            current, latest_version
262        ));
263    }
264
265    if check_only {
266        return Ok(());
267    }
268
269    let target = detect_target()?;
270    ui::info(&format!("Platform: {}", target));
271
272    let current_exe =
273        std::env::current_exe().context("Failed to determine path of current executable")?;
274    let current_exe = current_exe.canonicalize().unwrap_or(current_exe);
275
276    let tmp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
277
278    download_release(&latest_tag, target, tmp_dir.path())?;
279    extract_and_install(target, tmp_dir.path(), &current_exe)?;
280
281    ui::success(&format!("\nSuccessfully upgraded to {}!", latest_tag));
282
283    // Verify the new binary works
284    let output = run_host(current_exe.to_str().unwrap(), &["--version"])?;
285    if output.status.success() {
286        let version_output = String::from_utf8_lossy(&output.stdout);
287        ui::success(&format!("Verified: {}", version_output.trim()));
288    }
289
290    Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_current_version_non_empty() {
299        let v = current_version();
300        assert!(!v.is_empty());
301        assert!(v.contains('.'), "Version should contain dots: {}", v);
302    }
303
304    #[test]
305    fn test_strip_v_prefix() {
306        assert_eq!(strip_v_prefix("v0.1.0"), "0.1.0");
307        assert_eq!(strip_v_prefix("0.1.0"), "0.1.0");
308        assert_eq!(strip_v_prefix("v1.2.3-beta"), "1.2.3-beta");
309    }
310
311    #[test]
312    fn test_detect_target_succeeds() {
313        let target = detect_target().unwrap();
314        let valid_targets = [
315            "aarch64-apple-darwin",
316            "x86_64-apple-darwin",
317            "x86_64-unknown-linux-gnu",
318            "aarch64-unknown-linux-gnu",
319        ];
320        assert!(
321            valid_targets.contains(&target),
322            "Unexpected target: {}",
323            target
324        );
325    }
326}