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
10fn current_version() -> &'static str {
12 env!("CARGO_PKG_VERSION")
13}
14
15fn 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
43fn 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
60fn strip_v_prefix(tag: &str) -> &str {
62 tag.strip_prefix('v').unwrap_or(tag)
63}
64
65fn 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
88fn is_writable(path: &Path) -> bool {
90 tempfile::Builder::new()
91 .prefix(".mvm-write-test-")
92 .tempfile_in(path)
93 .is_ok()
94}
95
96fn 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 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 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
222fn 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
238pub 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(), ¤t_exe)?;
280
281 ui::success(&format!("\nSuccessfully upgraded to {}!", latest_tag));
282
283 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}