1use std::env;
8use std::fs;
9use std::io::Write;
10use std::path::PathBuf;
11
12use anyhow::{Context, Result, bail};
13use serde::Deserialize;
14
15use crate::update_check::{detect_install_method, is_newer_version};
16
17const GITHUB_OWNER: &str = "meteora-pro";
19
20const GITHUB_REPO: &str = "devboy-tools";
22
23const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
25
26#[derive(Debug, Deserialize)]
28struct Release {
29 tag_name: String,
30 assets: Vec<Asset>,
31}
32
33#[derive(Debug, Deserialize)]
35struct Asset {
36 name: String,
37 browser_download_url: String,
38}
39
40fn get_asset_name() -> Result<String> {
42 let name = match (env::consts::OS, env::consts::ARCH) {
43 ("linux", "x86_64") => "devboy-linux-x86_64.tar.gz",
44 ("linux", "aarch64") => "devboy-linux-arm64.tar.gz",
45 ("macos", "x86_64") => "devboy-macos-x86_64.tar.gz",
46 ("macos", "aarch64") => "devboy-macos-arm64.tar.gz",
47 ("windows", "x86_64") => "devboy-windows-x86_64.exe.zip",
48 (os, arch) => bail!("Unsupported platform: {os}/{arch}"),
49 };
50 Ok(name.to_string())
51}
52
53fn github_api_request(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
58 let mut req = client.get(url);
59 if let Ok(token) = env::var("GITHUB_TOKEN").or_else(|_| env::var("GH_TOKEN"))
60 && !token.is_empty()
61 {
62 req = req.bearer_auth(token);
63 }
64 req
65}
66
67async fn fetch_latest_release() -> Result<Release> {
69 let url = format!(
70 "https://api.github.com/repos/{}/{}/releases/latest",
71 GITHUB_OWNER, GITHUB_REPO
72 );
73
74 let client = reqwest::Client::builder()
75 .timeout(DOWNLOAD_TIMEOUT)
76 .user_agent(format!("devboy/{}", env!("CARGO_PKG_VERSION")))
77 .build()?;
78
79 let response = github_api_request(&client, &url)
80 .send()
81 .await
82 .context("Failed to fetch release info from GitHub")?;
83
84 if !response.status().is_success() {
85 bail!(
86 "GitHub API returned status {}: {}",
87 response.status(),
88 response.text().await.unwrap_or_default()
89 );
90 }
91
92 response
93 .json()
94 .await
95 .context("Failed to parse GitHub release response")
96}
97
98async fn download_asset(url: &str) -> Result<Vec<u8>> {
100 let client = reqwest::Client::builder()
101 .timeout(DOWNLOAD_TIMEOUT)
102 .user_agent(format!("devboy/{}", env!("CARGO_PKG_VERSION")))
103 .build()?;
104
105 let response = client
106 .get(url)
107 .send()
108 .await
109 .context("Failed to download release asset")?;
110
111 if !response.status().is_success() {
112 bail!("Failed to download asset: HTTP {}", response.status());
113 }
114
115 let bytes = response
116 .bytes()
117 .await
118 .context("Failed to read asset bytes")?;
119
120 Ok(bytes.to_vec())
121}
122
123fn extract_tar_gz(data: &[u8]) -> Result<Vec<u8>> {
125 use flate2::read::GzDecoder;
126 use tar::Archive;
127
128 let decoder = GzDecoder::new(data);
129 let mut archive = Archive::new(decoder);
130
131 for entry in archive.entries().context("Failed to read tar entries")? {
132 let mut entry = entry.context("Failed to read tar entry")?;
133 let path = entry.path().context("Failed to read entry path")?;
134
135 if path.file_name().and_then(|n| n.to_str()) == Some("devboy") {
136 let mut buf = Vec::new();
137 std::io::Read::read_to_end(&mut entry, &mut buf)?;
138 return Ok(buf);
139 }
140 }
141
142 bail!("Binary 'devboy' not found in archive")
143}
144
145fn extract_zip(data: &[u8]) -> Result<Vec<u8>> {
147 use std::io::Cursor;
148 let reader = Cursor::new(data);
149 let mut archive = zip::ZipArchive::new(reader).context("Failed to read zip archive")?;
150
151 for i in 0..archive.len() {
152 let mut file = archive.by_index(i).context("Failed to read zip entry")?;
153 let name = file.name().to_string();
154
155 if name == "devboy.exe" || name == "devboy" {
156 let mut buf = Vec::new();
157 std::io::Read::read_to_end(&mut file, &mut buf)?;
158 return Ok(buf);
159 }
160 }
161
162 bail!("Binary not found in zip archive")
163}
164
165fn replace_binary(new_binary: &[u8]) -> Result<PathBuf> {
173 let current_exe = env::current_exe().context("Failed to get current executable path")?;
174 let current_exe = current_exe.canonicalize().unwrap_or(current_exe);
175
176 #[cfg(unix)]
177 {
178 use std::os::unix::fs::PermissionsExt;
179
180 let temp_path = current_exe.with_extension("new");
181 fs::write(&temp_path, new_binary).context("Failed to write new binary")?;
182 fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o755))
183 .context("Failed to set executable permissions")?;
184 fs::rename(&temp_path, ¤t_exe).context("Failed to replace binary (atomic rename)")?;
185 }
186
187 #[cfg(windows)]
188 {
189 let temp_path = current_exe.with_extension("new.exe");
190 fs::write(&temp_path, new_binary).context("Failed to write new binary")?;
191
192 let current_str = current_exe.to_string_lossy();
195 let temp_str = temp_path.to_string_lossy();
196
197 let script = format!(
200 "ping 127.0.0.1 -n 3 >nul & move /Y \"{}\" \"{}\"",
201 temp_str, current_str,
202 );
203
204 std::process::Command::new("cmd")
205 .args(["/C", &script])
206 .stdin(std::process::Stdio::null())
207 .stdout(std::process::Stdio::null())
208 .stderr(std::process::Stdio::null())
209 .spawn()
210 .context("Failed to spawn helper process for binary replacement")?;
211 }
212
213 #[cfg(not(any(unix, windows)))]
214 {
215 bail!(
216 "Self-update is not supported on this platform. Download the binary manually from GitHub Releases."
217 );
218 }
219
220 Ok(current_exe)
221}
222
223fn run_managed_upgrade(install_method: &crate::update_check::InstallMethod) -> Result<()> {
232 let cmd_str = install_method.update_command();
233
234 println!(
235 "Installation managed by {}. Running: \x1b[1m{}\x1b[0m\n",
236 install_method.name(),
237 cmd_str
238 );
239
240 #[cfg(windows)]
241 {
242 let script = format!("ping 127.0.0.1 -n 3 >nul & {}", cmd_str);
247
248 std::process::Command::new("cmd")
249 .args(["/C", &script])
250 .stdin(std::process::Stdio::null())
251 .stdout(std::process::Stdio::null())
252 .stderr(std::process::Stdio::null())
253 .spawn()
254 .context("Failed to spawn helper process for upgrade")?;
255
256 println!("\x1b[33mThe upgrade will run in the background after this process exits.\x1b[0m");
257 Ok(())
258 }
259
260 #[cfg(not(windows))]
261 {
262 let (program, args) = install_method.update_command_parts();
263 let status = std::process::Command::new(program)
264 .args(args)
265 .stdin(std::process::Stdio::inherit())
266 .stdout(std::process::Stdio::inherit())
267 .stderr(std::process::Stdio::inherit())
268 .status()
269 .with_context(|| {
270 format!(
271 "Failed to run '{}'. Is {} installed?",
272 program,
273 install_method.name()
274 )
275 })?;
276
277 if !status.success() {
278 bail!(
279 "{} exited with {}. You can try manually: {}",
280 program,
281 status,
282 cmd_str
283 );
284 }
285
286 println!(
287 "\n\x1b[32m✓ Successfully upgraded via {}\x1b[0m",
288 install_method.name()
289 );
290 Ok(())
291 }
292}
293
294pub async fn run_upgrade(check_only: bool) -> Result<()> {
298 let current_version = env!("CARGO_PKG_VERSION");
299
300 let install_method = detect_install_method();
302
303 if install_method.is_managed() && !check_only {
304 return run_managed_upgrade(&install_method);
305 }
306
307 println!("Current version: {}", current_version);
308 println!("Checking for updates...");
309
310 let release = fetch_latest_release().await?;
311 let latest_version = release
312 .tag_name
313 .strip_prefix('v')
314 .unwrap_or(&release.tag_name);
315
316 if !is_newer_version(current_version, latest_version) {
317 println!(
318 "You are already running the latest version ({}).",
319 current_version
320 );
321 return Ok(());
322 }
323
324 println!(
325 "New version available: {} → {}",
326 current_version, latest_version
327 );
328
329 if check_only {
330 let update_cmd = install_method.update_command();
331 println!("Update with: \x1b[1m{}\x1b[0m", update_cmd);
332 return Ok(());
333 }
334
335 let asset_name = get_asset_name()?;
337 let asset = release
338 .assets
339 .iter()
340 .find(|a| a.name == asset_name)
341 .with_context(|| {
342 format!(
343 "Release asset '{}' not found. Available assets: {}",
344 asset_name,
345 release
346 .assets
347 .iter()
348 .map(|a| a.name.as_str())
349 .collect::<Vec<_>>()
350 .join(", ")
351 )
352 })?;
353
354 print!("Downloading {}...", asset_name);
355 std::io::stdout().flush()?;
356
357 let data = download_asset(&asset.browser_download_url).await?;
358 println!(" done ({:.1} MB)", data.len() as f64 / 1_048_576.0);
359
360 print!("Extracting binary...");
361 std::io::stdout().flush()?;
362
363 let binary = if asset_name.ends_with(".tar.gz") {
364 extract_tar_gz(&data)?
365 } else if asset_name.ends_with(".zip") {
366 extract_zip(&data)?
367 } else {
368 bail!("Unknown archive format: {}", asset_name);
369 };
370 println!(" done");
371
372 print!("Replacing binary...");
373 std::io::stdout().flush()?;
374
375 let path = replace_binary(&binary)?;
376 println!(" done");
377
378 println!(
379 "\n\x1b[32m✓ Successfully upgraded devboy {} → {}\x1b[0m\n \
380 Binary: {}",
381 current_version,
382 latest_version,
383 path.display()
384 );
385
386 #[cfg(windows)]
387 println!(
388 "\n\x1b[33mNote: The binary will be replaced in a few seconds after this process exits.\x1b[0m"
389 );
390
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn test_get_asset_name() {
400 let name = get_asset_name().unwrap();
401 assert!(
402 name.starts_with("devboy-"),
403 "Asset name should start with 'devboy-': {}",
404 name
405 );
406 assert!(
407 name.ends_with(".tar.gz") || name.ends_with(".zip"),
408 "Asset name should end with .tar.gz or .zip: {}",
409 name
410 );
411 }
412
413 #[test]
414 fn test_extract_tar_gz_valid() {
415 let mut builder = tar::Builder::new(Vec::new());
417
418 let content = b"fake binary content for testing";
419 let mut header = tar::Header::new_gnu();
420 header.set_size(content.len() as u64);
421 header.set_mode(0o755);
422 header.set_cksum();
423
424 builder
425 .append_data(&mut header, "devboy", &content[..])
426 .unwrap();
427
428 let tar_data = builder.into_inner().unwrap();
429
430 use flate2::Compression;
432 use flate2::write::GzEncoder;
433 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
434 std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
435 let gz_data = encoder.finish().unwrap();
436
437 let result = extract_tar_gz(&gz_data);
438 assert!(result.is_ok(), "Should extract devboy from tar.gz");
439 assert_eq!(result.unwrap(), content);
440 }
441
442 #[test]
443 fn test_extract_tar_gz_missing_binary() {
444 let mut builder = tar::Builder::new(Vec::new());
446
447 let content = b"not devboy";
448 let mut header = tar::Header::new_gnu();
449 header.set_size(content.len() as u64);
450 header.set_mode(0o644);
451 header.set_cksum();
452
453 builder
454 .append_data(&mut header, "other-file", &content[..])
455 .unwrap();
456
457 let tar_data = builder.into_inner().unwrap();
458
459 use flate2::Compression;
460 use flate2::write::GzEncoder;
461 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
462 std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
463 let gz_data = encoder.finish().unwrap();
464
465 let result = extract_tar_gz(&gz_data);
466 assert!(result.is_err(), "Should fail when devboy not in archive");
467 assert!(
468 result
469 .unwrap_err()
470 .to_string()
471 .contains("not found in archive"),
472 );
473 }
474
475 #[test]
476 fn test_extract_tar_gz_with_directory_prefix() {
477 let mut builder = tar::Builder::new(Vec::new());
479
480 let content = b"binary in subdir";
481 let mut header = tar::Header::new_gnu();
482 header.set_size(content.len() as u64);
483 header.set_mode(0o755);
484 header.set_cksum();
485
486 builder
487 .append_data(&mut header, "release/devboy", &content[..])
488 .unwrap();
489
490 let tar_data = builder.into_inner().unwrap();
491
492 use flate2::Compression;
493 use flate2::write::GzEncoder;
494 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
495 std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
496 let gz_data = encoder.finish().unwrap();
497
498 let result = extract_tar_gz(&gz_data);
500 assert!(result.is_ok(), "Should find devboy in subdirectory");
501 assert_eq!(result.unwrap(), content);
502 }
503
504 #[test]
505 fn test_extract_zip_valid() {
506 use std::io::Cursor;
507
508 let mut buf = Cursor::new(Vec::new());
509 {
510 let mut zip_writer = zip::ZipWriter::new(&mut buf);
511 let options = zip::write::SimpleFileOptions::default();
512 zip_writer.start_file("devboy.exe", options).unwrap();
513 std::io::Write::write_all(&mut zip_writer, b"fake exe content").unwrap();
514 zip_writer.finish().unwrap();
515 }
516
517 let zip_data = buf.into_inner();
518 let result = extract_zip(&zip_data);
519 assert!(result.is_ok(), "Should extract devboy.exe from zip");
520 assert_eq!(result.unwrap(), b"fake exe content");
521 }
522
523 #[test]
524 fn test_extract_zip_devboy_without_exe() {
525 use std::io::Cursor;
526
527 let mut buf = Cursor::new(Vec::new());
528 {
529 let mut zip_writer = zip::ZipWriter::new(&mut buf);
530 let options = zip::write::SimpleFileOptions::default();
531 zip_writer.start_file("devboy", options).unwrap();
532 std::io::Write::write_all(&mut zip_writer, b"unix binary").unwrap();
533 zip_writer.finish().unwrap();
534 }
535
536 let zip_data = buf.into_inner();
537 let result = extract_zip(&zip_data);
538 assert!(
539 result.is_ok(),
540 "Should extract devboy (without .exe) from zip"
541 );
542 assert_eq!(result.unwrap(), b"unix binary");
543 }
544
545 #[test]
546 fn test_extract_zip_missing_binary() {
547 use std::io::Cursor;
548
549 let mut buf = Cursor::new(Vec::new());
550 {
551 let mut zip_writer = zip::ZipWriter::new(&mut buf);
552 let options = zip::write::SimpleFileOptions::default();
553 zip_writer.start_file("readme.txt", options).unwrap();
554 std::io::Write::write_all(&mut zip_writer, b"not a binary").unwrap();
555 zip_writer.finish().unwrap();
556 }
557
558 let zip_data = buf.into_inner();
559 let result = extract_zip(&zip_data);
560 assert!(result.is_err(), "Should fail when binary not in zip");
561 assert!(result.unwrap_err().to_string().contains("not found"));
562 }
563
564 #[test]
565 fn test_extract_tar_gz_invalid_data() {
566 let result = extract_tar_gz(b"not a tar.gz file");
567 assert!(result.is_err(), "Should fail on invalid tar.gz data");
568 }
569
570 #[test]
571 fn test_extract_zip_invalid_data() {
572 let result = extract_zip(b"not a zip file");
573 assert!(result.is_err(), "Should fail on invalid zip data");
574 }
575
576 #[test]
577 fn test_replace_binary_creates_and_replaces() {
578 let dir = tempfile::tempdir().unwrap();
579 let binary_path = dir.path().join("devboy-test");
580
581 fs::write(&binary_path, b"old binary").unwrap();
583
584 #[cfg(unix)]
585 {
586 use std::os::unix::fs::PermissionsExt;
587 fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755)).unwrap();
588 }
589
590 let new_content = b"new binary content";
593 fs::write(&binary_path, new_content).unwrap();
594
595 let content = fs::read(&binary_path).unwrap();
596 assert_eq!(content, new_content);
597 }
598
599 #[test]
600 fn test_release_deserialization() {
601 let json = r#"{
602 "tag_name": "v1.2.3",
603 "assets": [
604 {
605 "name": "devboy-linux-x86_64.tar.gz",
606 "browser_download_url": "https://example.com/devboy-linux-x86_64.tar.gz"
607 },
608 {
609 "name": "devboy-macos-arm64.tar.gz",
610 "browser_download_url": "https://example.com/devboy-macos-arm64.tar.gz"
611 }
612 ]
613 }"#;
614
615 let release: Release = serde_json::from_str(json).unwrap();
616 assert_eq!(release.tag_name, "v1.2.3");
617 assert_eq!(release.assets.len(), 2);
618 assert_eq!(release.assets[0].name, "devboy-linux-x86_64.tar.gz");
619 assert_eq!(
620 release.assets[1].browser_download_url,
621 "https://example.com/devboy-macos-arm64.tar.gz"
622 );
623 }
624
625 #[test]
626 fn test_release_tag_name_strip_prefix() {
627 let tag = "v1.2.3";
628 let version = tag.strip_prefix('v').unwrap_or(tag);
629 assert_eq!(version, "1.2.3");
630
631 let tag_no_prefix = "1.2.3";
632 let version = tag_no_prefix.strip_prefix('v').unwrap_or(tag_no_prefix);
633 assert_eq!(version, "1.2.3");
634 }
635}