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(¤t_exe, binary.as_ref())?;
76 mark_executable(&temp_exe)?;
77 replace_current_executable(¤t_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}