Skip to main content

devboy_cli/
upgrade.rs

1//! Self-upgrade command implementation.
2//!
3//! Downloads and replaces the devboy binary with the latest version
4//! from GitHub Releases. Detects npm-managed installations and
5//! suggests the appropriate package manager command instead.
6
7use 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
17/// GitHub repository owner.
18const GITHUB_OWNER: &str = "meteora-pro";
19
20/// GitHub repository name.
21const GITHUB_REPO: &str = "devboy-tools";
22
23/// HTTP request timeout for download.
24const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
25
26/// GitHub Release API response.
27#[derive(Debug, Deserialize)]
28struct Release {
29    tag_name: String,
30    assets: Vec<Asset>,
31}
32
33/// GitHub Release asset.
34#[derive(Debug, Deserialize)]
35struct Asset {
36    name: String,
37    browser_download_url: String,
38}
39
40/// Get the expected asset name for the current platform.
41fn 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
53/// Build a GitHub API request with optional authentication.
54///
55/// Uses `GITHUB_TOKEN` or `GH_TOKEN` env var for authentication if available,
56/// which increases the rate limit from 60 to 5000 requests/hour.
57fn 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
67/// Fetch the latest release info from GitHub.
68async 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
98/// Download an asset from GitHub and return its bytes.
99async 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
123/// Extract the devboy binary from a tar.gz archive.
124fn 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
145/// Extract the devboy binary from a zip archive.
146fn 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
165/// Replace the current binary with new content.
166///
167/// - **Unix**: writes to a temp file and performs an atomic rename.
168/// - **Windows**: writes the new binary next to the current one, then spawns
169///   a helper `cmd.exe` process that waits for this process to exit and
170///   replaces the executable. This is necessary because Windows locks running
171///   executables, preventing in-place replacement.
172fn 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, &current_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        // Spawn a helper process that waits for us to exit, then moves the
193        // new binary over the old one and cleans up.
194        let current_str = current_exe.to_string_lossy();
195        let temp_str = temp_path.to_string_lossy();
196
197        // ping localhost is a classic Windows trick to sleep without `timeout`
198        // (which requires a console). 3 pings ≈ 2 seconds.
199        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
223/// Run upgrade via the detected package manager (npm/pnpm/yarn).
224///
225/// Spawns the package manager as a child process, inheriting
226/// stdout/stderr so the user sees real-time progress.
227///
228/// On Windows, the running executable is locked by the OS, so the
229/// package manager cannot replace it in-place. In that case we fall
230/// back to printing the command for the user to run manually.
231fn 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        // Windows locks the running .exe, so the package manager cannot
243        // replace it while we are alive. Spawn a detached `cmd.exe` that
244        // waits for this process to exit and then runs the update.
245        // 3 pings ≈ 2 seconds — classic Windows sleep-without-console trick.
246        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
294/// Run the upgrade command.
295///
296/// If `check_only` is true, only checks for updates without installing.
297pub async fn run_upgrade(check_only: bool) -> Result<()> {
298    let current_version = env!("CARGO_PKG_VERSION");
299
300    // Check installation method first
301    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    // Find the right asset for this platform
336    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        // Create a tar.gz archive with a "devboy" file in memory
416        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        // Compress with gzip
431        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        // Create a tar.gz with a different filename
445        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        // Create tar.gz where devboy is in a subdirectory
478        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        // Should still find it by file_name()
499        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        // Create initial binary
582        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        // We can't easily test replace_binary because it uses current_exe(),
591        // but we can test the extraction + write pipeline
592        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}