Skip to main content

oxide_cli/
upgrade.rs

1use std::{
2  env, fs,
3  path::{Path, PathBuf},
4};
5
6use anyhow::{Context, Result, anyhow};
7use chrono::{DateTime, Duration as ChronoDuration, Utc};
8use reqwest::{
9  Client,
10  header::{ACCEPT, USER_AGENT},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::AppContext;
15
16const RELEASES_API_URL: &str = "https://api.github.com/repos/oxide-cli/oxide/releases/latest";
17const RELEASES_DOWNLOAD_BASE_URL: &str = "https://github.com/oxide-cli/oxide/releases/download";
18
19#[derive(Debug, Deserialize)]
20struct LatestReleaseResponse {
21  tag_name: String,
22}
23
24#[derive(Debug, Deserialize, Serialize)]
25struct VersionCheckCache {
26  last_checked: String,
27  latest_version: String,
28}
29
30pub async fn check_latest_cli_version(client: &Client) -> Result<String> {
31  let release: LatestReleaseResponse = client
32    .get(releases_api_url())
33    .header(ACCEPT, "application/vnd.github+json")
34    .header(USER_AGENT, github_user_agent())
35    .send()
36    .await
37    .context("Failed to query the latest Oxide release")?
38    .error_for_status()
39    .context("GitHub releases endpoint returned an error")?
40    .json()
41    .await
42    .context("Failed to parse latest Oxide release metadata")?;
43
44  normalize_version_tag(&release.tag_name)
45}
46
47pub async fn upgrade_cli(ctx: &AppContext) -> Result<()> {
48  let current_version = env!("CARGO_PKG_VERSION");
49
50  println!("Checking for updates...");
51  let latest_version = check_latest_cli_version(&ctx.client).await?;
52  if !is_newer_version(current_version, &latest_version)? {
53    println!("Oxide v{current_version} is already the latest version.");
54    return Ok(());
55  }
56
57  let platform = current_platform()?;
58  let asset_url = release_asset_url(&latest_version, platform);
59  let current_exe = env::current_exe().context("Failed to locate the current Oxide executable")?;
60
61  println!("Downloading Oxide v{latest_version}...");
62  let binary = ctx
63    .client
64    .get(&asset_url)
65    .header(USER_AGENT, github_user_agent())
66    .send()
67    .await
68    .with_context(|| format!("Failed to download Oxide v{latest_version}"))?
69    .error_for_status()
70    .with_context(|| format!("GitHub release asset was not available at {asset_url}"))?
71    .bytes()
72    .await
73    .with_context(|| format!("Failed to read the downloaded Oxide v{latest_version} binary"))?;
74
75  let temp_exe = write_temp_binary(&current_exe, binary.as_ref())?;
76  mark_executable(&temp_exe)?;
77  replace_current_executable(&current_exe, &temp_exe)?;
78
79  println!("✓ Oxide updated to v{latest_version}. Restart your shell if needed.");
80  Ok(())
81}
82
83pub async fn check_cli_version_cached(client: &Client, path: &Path) -> Result<Option<String>> {
84  if let Some(cache) = read_version_check_cache(path)?
85    && is_cache_fresh(&cache, Utc::now())
86  {
87    return newer_version_if_available(&cache.latest_version);
88  }
89
90  let latest_version = check_latest_cli_version(client).await?;
91  write_version_check_cache(
92    path,
93    &VersionCheckCache {
94      last_checked: Utc::now().to_rfc3339(),
95      latest_version: latest_version.clone(),
96    },
97  )?;
98
99  newer_version_if_available(&latest_version)
100}
101
102pub fn render_upgrade_notice(latest_version: &str) -> String {
103  format!(
104    "\n  A new version of Oxide is available: v{} → v{}\n  Run `oxide upgrade` to update.",
105    env!("CARGO_PKG_VERSION"),
106    latest_version
107  )
108}
109
110fn github_user_agent() -> String {
111  format!("oxide-cli/{}", env!("CARGO_PKG_VERSION"))
112}
113
114fn releases_api_url() -> String {
115  env::var("OXIDE_RELEASES_API_URL").unwrap_or_else(|_| RELEASES_API_URL.to_string())
116}
117
118fn releases_download_base_url() -> String {
119  env::var("OXIDE_RELEASES_DOWNLOAD_BASE_URL")
120    .unwrap_or_else(|_| RELEASES_DOWNLOAD_BASE_URL.to_string())
121}
122
123fn normalize_version_tag(tag_name: &str) -> Result<String> {
124  let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
125  parse_version(version)?;
126  Ok(version.to_string())
127}
128
129#[doc(hidden)]
130pub fn normalize_version_tag_for_tests(tag_name: &str) -> Result<String> {
131  normalize_version_tag(tag_name)
132}
133
134fn read_version_check_cache(path: &Path) -> Result<Option<VersionCheckCache>> {
135  if !path.exists() {
136    return Ok(None);
137  }
138
139  let content = fs::read_to_string(path)
140    .with_context(|| format!("Failed to read version cache at {}", path.display()))?;
141  let cache = match serde_json::from_str::<VersionCheckCache>(&content) {
142    Ok(cache) => cache,
143    Err(_) => return Ok(None),
144  };
145  Ok(Some(cache))
146}
147
148fn write_version_check_cache(path: &Path, cache: &VersionCheckCache) -> Result<()> {
149  if let Some(parent) = path.parent() {
150    fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
151  }
152
153  fs::write(path, serde_json::to_string_pretty(cache)?)
154    .with_context(|| format!("Failed to write version cache to {}", path.display()))?;
155  Ok(())
156}
157
158fn parse_version(version: &str) -> Result<(u64, u64, u64)> {
159  let mut parts = version.split('.');
160  let major = parse_version_component(parts.next(), "major", version)?;
161  let minor = parse_version_component(parts.next(), "minor", version)?;
162  let patch = parse_version_component(parts.next(), "patch", version)?;
163  if parts.next().is_some() {
164    return Err(anyhow!("Unsupported version format '{version}'"));
165  }
166
167  Ok((major, minor, patch))
168}
169
170#[doc(hidden)]
171pub fn parse_version_for_tests(version: &str) -> Result<(u64, u64, u64)> {
172  parse_version(version)
173}
174
175fn parse_version_component(component: Option<&str>, label: &str, version: &str) -> Result<u64> {
176  let component =
177    component.ok_or_else(|| anyhow!("Missing {label} version component in '{version}'"))?;
178  component
179    .parse::<u64>()
180    .with_context(|| format!("Invalid {label} version component in '{version}'"))
181}
182
183fn is_newer_version(current: &str, latest: &str) -> Result<bool> {
184  Ok(parse_version(latest)? > parse_version(current)?)
185}
186
187#[doc(hidden)]
188pub fn is_newer_version_for_tests(current: &str, latest: &str) -> Result<bool> {
189  is_newer_version(current, latest)
190}
191
192fn newer_version_if_available(latest: &str) -> Result<Option<String>> {
193  if is_newer_version(env!("CARGO_PKG_VERSION"), latest)? {
194    Ok(Some(latest.to_string()))
195  } else {
196    Ok(None)
197  }
198}
199
200fn is_cache_fresh(cache: &VersionCheckCache, now: DateTime<Utc>) -> bool {
201  let Ok(last_checked) = DateTime::parse_from_rfc3339(&cache.last_checked) else {
202    return false;
203  };
204
205  now.signed_duration_since(last_checked.with_timezone(&Utc)) < ChronoDuration::hours(1)
206}
207
208#[doc(hidden)]
209pub fn is_cache_fresh_for_tests(
210  last_checked: &str,
211  latest_version: &str,
212  now: DateTime<Utc>,
213) -> bool {
214  is_cache_fresh(
215    &VersionCheckCache {
216      last_checked: last_checked.to_string(),
217      latest_version: latest_version.to_string(),
218    },
219    now,
220  )
221}
222
223fn current_platform() -> Result<&'static str> {
224  if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
225    Ok("linux-x86_64")
226  } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
227    Ok("macos-aarch64")
228  } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
229    Ok("windows-x86_64")
230  } else {
231    Err(anyhow!(
232      "Unsupported platform for self-update: {}-{}",
233      env::consts::OS,
234      env::consts::ARCH
235    ))
236  }
237}
238
239fn asset_filename(platform: &str) -> String {
240  if platform.starts_with("windows-") {
241    format!("oxide-{platform}.exe")
242  } else {
243    format!("oxide-{platform}")
244  }
245}
246
247#[doc(hidden)]
248pub fn asset_filename_for_tests(platform: &str) -> String {
249  asset_filename(platform)
250}
251
252fn release_asset_url(version: &str, platform: &str) -> String {
253  format!(
254    "{}/v{version}/{}",
255    releases_download_base_url(),
256    asset_filename(platform)
257  )
258}
259
260#[doc(hidden)]
261pub fn release_asset_url_for_tests(version: &str, platform: &str) -> String {
262  release_asset_url(version, platform)
263}
264
265fn write_temp_binary(current_exe: &Path, binary: &[u8]) -> Result<PathBuf> {
266  let exe_dir = current_exe
267    .parent()
268    .ok_or_else(|| anyhow!("Failed to resolve the executable directory"))?;
269  let exe_name = current_exe
270    .file_name()
271    .and_then(|name| name.to_str())
272    .ok_or_else(|| anyhow!("Executable path is not valid UTF-8"))?;
273  let temp_path = exe_dir.join(format!("{exe_name}.upgrade-{}.tmp", std::process::id()));
274  fs::write(&temp_path, binary).with_context(|| {
275    format!(
276      "Failed to write downloaded binary to {}",
277      temp_path.display()
278    )
279  })?;
280  Ok(temp_path)
281}
282
283#[cfg(unix)]
284fn mark_executable(path: &Path) -> Result<()> {
285  use std::os::unix::fs::PermissionsExt;
286
287  let mut permissions = fs::metadata(path)
288    .with_context(|| format!("Failed to read permissions for {}", path.display()))?
289    .permissions();
290  permissions.set_mode(0o755);
291  fs::set_permissions(path, permissions)
292    .with_context(|| format!("Failed to mark {} as executable", path.display()))?;
293  Ok(())
294}
295
296#[cfg(not(unix))]
297fn mark_executable(_path: &Path) -> Result<()> {
298  Ok(())
299}
300
301#[cfg(not(windows))]
302fn replace_current_executable(current_exe: &Path, temp_exe: &Path) -> Result<()> {
303  fs::rename(temp_exe, current_exe).with_context(|| {
304    format!(
305      "Failed to replace {} with {}",
306      current_exe.display(),
307      temp_exe.display()
308    )
309  })?;
310  Ok(())
311}
312
313#[cfg(windows)]
314fn replace_current_executable(current_exe: &Path, temp_exe: &Path) -> Result<()> {
315  use std::process::Command;
316
317  let updater_script =
318    current_exe.with_file_name(format!("oxide-upgrade-{}.cmd", std::process::id()));
319  let script = build_windows_updater_script(current_exe, temp_exe, &updater_script)?;
320  fs::write(&updater_script, script)
321    .with_context(|| format!("Failed to write {}", updater_script.display()))?;
322
323  let updater_script = path_for_shell(&updater_script)?;
324  Command::new("cmd")
325    .args(["/C", "start", "", "/B", updater_script.as_str()])
326    .spawn()
327    .context("Failed to start the Windows updater helper")?;
328  Ok(())
329}
330
331#[cfg(windows)]
332fn build_windows_updater_script(
333  current_exe: &Path,
334  temp_exe: &Path,
335  updater_script: &Path,
336) -> Result<String> {
337  let current_exe = quoted_windows_path(current_exe)?;
338  let temp_exe = quoted_windows_path(temp_exe)?;
339  let updater_script = quoted_windows_path(updater_script)?;
340  Ok(format!(
341    "@echo off\r\nping 127.0.0.1 -n 3 > nul\r\nmove /Y {temp_exe} {current_exe} > nul\r\ndel /Q {updater_script} > nul\r\n"
342  ))
343}
344
345#[cfg(windows)]
346fn quoted_windows_path(path: &Path) -> Result<String> {
347  Ok(format!(
348    "\"{}\"",
349    path_for_shell(path)?.replace('"', "\"\"")
350  ))
351}
352
353#[cfg(windows)]
354fn path_for_shell(path: &Path) -> Result<String> {
355  path
356    .to_str()
357    .map(ToOwned::to_owned)
358    .ok_or_else(|| anyhow!("Path '{}' is not valid UTF-8", path.display()))
359}