vx_cli/commands/
self_update.rs1use 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
13pub 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
27async 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
47async 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 let current_exe = env::current_exe().context("Failed to get current executable path")?;
70
71 download_and_replace(¤t_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
80fn get_current_version() -> Result<String> {
82 Ok(env!("CARGO_PKG_VERSION").to_string())
84}
85
86async 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 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 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 Ok(tag_name.trim_start_matches('v').to_string())
133}
134
135async fn download_and_replace(current_exe: &PathBuf, version: &str) -> Result<()> {
137 use std::fs;
138 use std::io::Write;
139
140 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 let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
152
153 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 UI::info("Extracting update...");
178 extract_archive(&archive_path, temp_dir.path())?;
179
180 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 UI::info("Installing update...");
186 replace_executable(current_exe, &new_exe_path)?;
187
188 Ok(())
189}
190
191fn 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" };
208
209 let variant = if cfg!(target_os = "windows") {
210 "msvc"
211 } else {
212 "gnu"
213 };
214
215 format!("{}-{}-{}", os, variant, arch)
216}
217
218fn 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
251fn 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
273fn replace_executable(current_exe: &PathBuf, new_exe: &PathBuf) -> Result<()> {
275 use std::fs;
276
277 #[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 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 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}