vx_cli/commands/
self_update.rs

1//! Self-update command implementation
2//! Allows vx to update itself to the latest version
3
4use crate::ui::UI;
5use anyhow::{Context, Result};
6use std::env;
7use std::path::PathBuf;
8use tracing::{info_span, Instrument};
9
10const GITHUB_OWNER: &str = "loonghao";
11const GITHUB_REPO: &str = "vx";
12
13/// Handle self-update command
14pub async fn handle(check_only: bool, version: Option<&str>) -> Result<()> {
15    let span = info_span!("Self-update", check_only = check_only);
16    async {
17        if check_only {
18            check_for_updates().await
19        } else {
20            perform_self_update(version).await
21        }
22    }
23    .instrument(span)
24    .await
25}
26
27/// Check for available updates
28async fn check_for_updates() -> Result<()> {
29    UI::info("Checking for vx updates...");
30
31    let current_version = get_current_version()?;
32    let latest_version = get_latest_version().await?;
33
34    if current_version == latest_version {
35        UI::success(&format!("vx {} is up to date", current_version));
36    } else {
37        UI::info(&format!(
38            "Update available: {} → {}",
39            current_version, latest_version
40        ));
41        UI::hint("Run 'vx self-update' to update");
42    }
43
44    Ok(())
45}
46
47/// Perform self-update
48async fn perform_self_update(target_version: Option<&str>) -> Result<()> {
49    let current_version = get_current_version()?;
50
51    let target_version = if let Some(v) = target_version {
52        v.to_string()
53    } else {
54        UI::info("Fetching latest version...");
55        get_latest_version().await?
56    };
57
58    if current_version == target_version {
59        UI::success(&format!("vx {} is already up to date", current_version));
60        return Ok(());
61    }
62
63    UI::info(&format!(
64        "Updating vx: {} → {}",
65        current_version, target_version
66    ));
67
68    // Get current executable path
69    let current_exe = env::current_exe().context("Failed to get current executable path")?;
70
71    // Download and replace the executable
72    download_and_replace(&current_exe, &target_version).await?;
73
74    UI::success(&format!("Successfully updated vx to {}", target_version));
75    UI::hint("Restart your terminal to ensure the new version is loaded");
76
77    Ok(())
78}
79
80/// Get current vx version
81fn get_current_version() -> Result<String> {
82    // Get version from Cargo.toml or environment
83    Ok(env!("CARGO_PKG_VERSION").to_string())
84}
85
86/// Get latest version from GitHub API
87async fn get_latest_version() -> Result<String> {
88    let client = reqwest::Client::new();
89    let url = format!(
90        "https://api.github.com/repos/{}/{}/releases/latest",
91        GITHUB_OWNER, GITHUB_REPO
92    );
93
94    let mut request = client
95        .get(&url)
96        .header("User-Agent", format!("vx/{}", get_current_version()?));
97
98    // Add GitHub token if available in environment
99    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
100        request = request.header("Authorization", format!("Bearer {}", token));
101    }
102
103    let response = request
104        .send()
105        .await
106        .context("Failed to fetch latest release information")?;
107
108    if !response.status().is_success() {
109        let status = response.status();
110        let error_text = response.text().await.unwrap_or_default();
111
112        // Handle rate limiting gracefully
113        if status == 403 && error_text.contains("rate limit") {
114            anyhow::bail!(
115                "GitHub API rate limit exceeded. To avoid this, set GITHUB_TOKEN environment variable with a personal access token."
116            );
117        }
118
119        anyhow::bail!("GitHub API request failed: {} - {}", status, error_text);
120    }
121
122    let release: serde_json::Value = response
123        .json()
124        .await
125        .context("Failed to parse GitHub API response")?;
126
127    let tag_name = release["tag_name"]
128        .as_str()
129        .context("Missing tag_name in release")?;
130
131    // Remove 'v' prefix if present
132    Ok(tag_name.trim_start_matches('v').to_string())
133}
134
135/// Download and replace the current executable
136async fn download_and_replace(current_exe: &PathBuf, version: &str) -> Result<()> {
137    use std::fs;
138    use std::io::Write;
139
140    // Determine platform-specific binary name
141    let platform = get_platform_string();
142    let archive_name = format!("vx-{}.zip", platform);
143    let download_url = format!(
144        "https://github.com/{}/{}/releases/download/v{}/{}",
145        GITHUB_OWNER, GITHUB_REPO, version, archive_name
146    );
147
148    UI::info(&format!("Downloading vx {} for {}...", version, platform));
149
150    // Create temporary directory
151    let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
152
153    // Download the archive
154    let client = reqwest::Client::new();
155    let response = client
156        .get(&download_url)
157        .send()
158        .await
159        .context("Failed to download update")?;
160
161    if !response.status().is_success() {
162        anyhow::bail!("Download failed: {}", response.status());
163    }
164
165    let archive_path = temp_dir.path().join(&archive_name);
166    let mut file = fs::File::create(&archive_path).context("Failed to create temporary file")?;
167
168    let content = response
169        .bytes()
170        .await
171        .context("Failed to read download content")?;
172
173    file.write_all(&content)
174        .context("Failed to write download content")?;
175
176    // Extract the archive
177    UI::info("Extracting update...");
178    extract_archive(&archive_path, temp_dir.path())?;
179
180    // Find the new executable
181    let new_exe_name = if cfg!(windows) { "vx.exe" } else { "vx" };
182    let new_exe_path = find_executable_in_dir(temp_dir.path(), new_exe_name)?;
183
184    // Replace the current executable
185    UI::info("Installing update...");
186    replace_executable(current_exe, &new_exe_path)?;
187
188    Ok(())
189}
190
191/// Get platform string for download URL
192fn get_platform_string() -> String {
193    let os = if cfg!(target_os = "windows") {
194        "Windows"
195    } else if cfg!(target_os = "macos") {
196        "Darwin"
197    } else {
198        "Linux"
199    };
200
201    let arch = if cfg!(target_arch = "x86_64") {
202        "x86_64"
203    } else if cfg!(target_arch = "aarch64") {
204        "aarch64"
205    } else {
206        "x86_64" // fallback
207    };
208
209    let variant = if cfg!(target_os = "windows") {
210        "msvc"
211    } else {
212        "gnu"
213    };
214
215    format!("{}-{}-{}", os, variant, arch)
216}
217
218/// Extract archive to directory
219fn extract_archive(archive_path: &PathBuf, extract_dir: &std::path::Path) -> Result<()> {
220    let file = std::fs::File::open(archive_path).context("Failed to open archive")?;
221
222    let mut archive = zip::ZipArchive::new(file).context("Failed to read ZIP archive")?;
223
224    for i in 0..archive.len() {
225        let mut file = archive
226            .by_index(i)
227            .context("Failed to read archive entry")?;
228
229        let outpath = match file.enclosed_name() {
230            Some(path) => extract_dir.join(path),
231            None => continue,
232        };
233
234        if file.name().ends_with('/') {
235            std::fs::create_dir_all(&outpath).context("Failed to create directory")?;
236        } else {
237            if let Some(p) = outpath.parent() {
238                if !p.exists() {
239                    std::fs::create_dir_all(p).context("Failed to create parent directory")?;
240                }
241            }
242            let mut outfile =
243                std::fs::File::create(&outpath).context("Failed to create output file")?;
244            std::io::copy(&mut file, &mut outfile).context("Failed to extract file")?;
245        }
246    }
247
248    Ok(())
249}
250
251/// Find executable in directory
252fn find_executable_in_dir(dir: &std::path::Path, exe_name: &str) -> Result<PathBuf> {
253    use std::fs;
254
255    for entry in fs::read_dir(dir).context("Failed to read directory")? {
256        let entry = entry.context("Failed to read directory entry")?;
257        let path = entry.path();
258
259        if path.is_file() && path.file_name().unwrap_or_default() == exe_name {
260            return Ok(path);
261        }
262
263        if path.is_dir() {
264            if let Ok(found) = find_executable_in_dir(&path, exe_name) {
265                return Ok(found);
266            }
267        }
268    }
269
270    anyhow::bail!("Executable {} not found in archive", exe_name);
271}
272
273/// Replace the current executable with the new one
274fn replace_executable(current_exe: &PathBuf, new_exe: &PathBuf) -> Result<()> {
275    use std::fs;
276
277    // On Windows, we might need to rename the current exe first
278    #[cfg(windows)]
279    {
280        let backup_path = current_exe.with_extension("exe.old");
281        if backup_path.exists() {
282            fs::remove_file(&backup_path).context("Failed to remove old backup")?;
283        }
284        fs::rename(current_exe, &backup_path).context("Failed to backup current executable")?;
285
286        fs::copy(new_exe, current_exe).context("Failed to install new executable")?;
287
288        // Try to remove backup, but don't fail if we can't
289        let _ = fs::remove_file(&backup_path);
290    }
291
292    #[cfg(not(windows))]
293    {
294        fs::copy(new_exe, current_exe).context("Failed to install new executable")?;
295
296        // Make executable
297        use std::os::unix::fs::PermissionsExt;
298        let mut perms = fs::metadata(current_exe)?.permissions();
299        perms.set_mode(0o755);
300        fs::set_permissions(current_exe, perms).context("Failed to set executable permissions")?;
301    }
302
303    Ok(())
304}