1use crate::discover::{self, InstallOrigin};
14use crate::error::Error;
15use crate::http::Http;
16use crate::installer::stream_to_file;
17use crate::mise;
18use crate::progress::{DownloadProgress, InstallPhase};
19use crate::{InstallerMode, RuntimeConfig};
20use std::path::{Path, PathBuf};
21
22const RELEASE_BASE: &str = "https://github.com/jdx/aube/releases/download";
26
27const VERSIONS_HOST: &str = "https://mise-versions.jdx.dev";
34
35const RELEASE_API_BASE: &str = "https://api.github.com/repos/jdx/aube/releases/tags";
41
42const VERSION_URL: &str = "https://aube.jdx.dev/VERSION";
45
46#[derive(Debug, Clone)]
48pub struct InstalledAube {
49 pub version: node_semver::Version,
50 pub install_dir: PathBuf,
51 pub exe: PathBuf,
53 pub origin: InstallOrigin,
54}
55
56pub fn self_dir() -> Option<PathBuf> {
59 if let Some(dir) = aube_util::env::embedder_env("SELF_DIR")
60 && !dir.is_empty()
61 {
62 return Some(PathBuf::from(dir));
63 }
64 #[cfg(windows)]
65 if let Ok(local) = std::env::var("LOCALAPPDATA") {
66 return Some(PathBuf::from(local).join("aube/self"));
67 }
68 let data_home = aube_util::env::xdg_data_home()
69 .or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?;
70 Some(data_home.join("aube/self"))
71}
72
73pub fn list_installed_aube() -> Vec<InstalledAube> {
77 let mut by_version: std::collections::BTreeMap<node_semver::Version, InstalledAube> =
78 Default::default();
79 if let Some(dir) = discover::mise_tool_installs_dir("aube") {
80 for install in scan_aube_dir(&dir, InstallOrigin::Mise) {
81 by_version.insert(install.version.clone(), install);
82 }
83 }
84 if let Some(dir) = self_dir() {
85 for install in scan_aube_dir(&dir, InstallOrigin::Aube) {
86 by_version.insert(install.version.clone(), install);
87 }
88 }
89 by_version.into_values().collect()
90}
91
92pub fn find_installed_aube(version: &node_semver::Version) -> Option<InstalledAube> {
95 let from_self = self_dir()
96 .map(|d| d.join(version.to_string()))
97 .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Aube));
98 from_self.or_else(|| {
99 discover::mise_tool_installs_dir("aube")
100 .map(|d| d.join(version.to_string()))
101 .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise))
102 })
103}
104
105fn scan_aube_dir(root: &Path, origin: InstallOrigin) -> Vec<InstalledAube> {
106 let Ok(entries) = std::fs::read_dir(root) else {
107 return Vec::new();
108 };
109 let mut out = Vec::new();
110 for entry in entries.flatten() {
111 let path = entry.path();
112 let Ok(file_type) = entry.file_type() else {
113 continue;
114 };
115 if !file_type.is_dir() {
116 continue;
118 }
119 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
120 continue;
121 };
122 let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else {
123 continue;
124 };
125 if let Some(install) = validate_aube_install(&path, version, origin) {
126 out.push(install);
127 }
128 }
129 out
130}
131
132fn validate_aube_install(
137 dir: &Path,
138 version: node_semver::Version,
139 origin: InstallOrigin,
140) -> Option<InstalledAube> {
141 if dir.join("incomplete").exists() {
142 return None;
143 }
144 let exe_name = if cfg!(windows) { "aube.exe" } else { "aube" };
145 let exe = [dir.join(exe_name), dir.join("bin").join(exe_name)]
146 .into_iter()
147 .find(|p| discover::is_executable_file(p))?;
148 Some(InstalledAube {
149 version,
150 install_dir: dir.to_path_buf(),
151 exe,
152 origin,
153 })
154}
155
156pub fn release_target_triple() -> Result<String, Error> {
163 let os = std::env::consts::OS;
164 let raw_arch = std::env::consts::ARCH;
165
166 if os == "freebsd" {
173 return Err(Error::NoReleaseBuild {
174 platform: format!("freebsd-{raw_arch}"),
175 });
176 }
177
178 let arch = match raw_arch {
179 "x86_64" => "x86_64",
180 "aarch64" => "aarch64",
181 other => {
182 return Err(Error::NoReleaseBuild {
183 platform: format!("{os}-{other}"),
184 });
185 }
186 };
187 let triple = match os {
188 "macos" => {
189 if arch != "aarch64" {
190 return Err(Error::NoReleaseBuild {
191 platform: "macos-x86_64".to_string(),
192 });
193 }
194 format!("{arch}-apple-darwin")
195 }
196 "linux" => {
197 let libc = if crate::Platform::current()?.libc.as_deref() == Some("musl") {
198 "musl"
199 } else {
200 "gnu"
201 };
202 format!("{arch}-unknown-linux-{libc}")
203 }
204 "windows" => format!("{arch}-pc-windows-msvc"),
205 other => {
206 return Err(Error::NoReleaseBuild {
207 platform: format!("{other}-{arch}"),
208 });
209 }
210 };
211 Ok(triple)
212}
213
214fn release_base() -> String {
215 aube_util::env::embedder_env("SELF_DOWNLOAD_BASE")
216 .and_then(|s| s.into_string().ok())
217 .filter(|s| !s.trim().is_empty())
218 .map(|s| s.trim_end_matches('/').to_string())
219 .unwrap_or_else(|| RELEASE_BASE.to_string())
220}
221
222fn versions_host() -> String {
223 aube_util::env::embedder_env("VERSIONS_HOST")
224 .and_then(|s| s.into_string().ok())
225 .filter(|s| !s.trim().is_empty())
226 .map(|s| s.trim_end_matches('/').to_string())
227 .unwrap_or_else(|| VERSIONS_HOST.to_string())
228}
229
230pub async fn available_aube_versions(retries: u32) -> Result<Vec<node_semver::Version>, Error> {
236 let http = Http::new(retries);
237 let list_url = format!("{}/aube", versions_host());
238 match fetch_text(&http, &list_url).await {
239 Ok(text) => {
240 let versions: Vec<node_semver::Version> = text
241 .lines()
242 .filter_map(|l| node_semver::Version::parse(l.trim().trim_start_matches('v')).ok())
243 .collect();
244 if !versions.is_empty() {
245 return Ok(versions);
246 }
247 tracing::debug!(%list_url, "versions host returned an empty list; falling back");
248 }
249 Err(e) => {
250 tracing::debug!(%list_url, error = %e, "versions host unreachable; falling back");
251 }
252 }
253 let url = aube_util::env::embedder_env("SELF_VERSION_URL")
254 .and_then(|s| s.into_string().ok())
255 .filter(|s| !s.trim().is_empty())
256 .unwrap_or_else(|| VERSION_URL.to_string());
257 let text = fetch_text(&http, &url).await?;
258 let latest = node_semver::Version::parse(text.trim().trim_start_matches('v')).map_err(|e| {
259 Error::DownloadFailed {
260 url,
261 reason: format!("unparseable version announcement: {e}"),
262 }
263 })?;
264 Ok(vec![latest])
265}
266
267async fn fetch_text(http: &Http, url: &str) -> Result<String, Error> {
268 let resp = http.get(url, None, None, false).await?;
269 let body = resp.body.ok_or_else(|| Error::DownloadFailed {
270 url: url.to_string(),
271 reason: "unexpected empty response".to_string(),
272 })?;
273 body.text().await.map_err(|e| Error::DownloadFailed {
274 url: url.to_string(),
275 reason: e.to_string(),
276 })
277}
278
279pub async fn install_aube(
283 cfg: &RuntimeConfig,
284 version: &node_semver::Version,
285 progress: &dyn DownloadProgress,
286) -> Result<InstalledAube, Error> {
287 if let Some(existing) = find_installed_aube(version) {
288 return Ok(existing);
289 }
290 match cfg.installer {
291 InstallerMode::Aube => self_download(cfg, version, progress).await,
292 InstallerMode::Mise => {
293 let Some(mise_bin) = mise::mise_on_path() else {
294 return Err(Error::MiseInstallFailed {
295 version: format!("aube@{version}"),
296 reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
297 });
298 };
299 delegate_to_mise(&mise_bin, version, progress).await
300 }
301 InstallerMode::Auto => match mise::mise_on_path() {
302 Some(mise_bin) => match delegate_to_mise(&mise_bin, version, progress).await {
303 Ok(install) => Ok(install),
304 Err(e) => {
305 tracing::warn!(
306 code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
307 error = %e,
308 "mise failed to install aube; falling back to a release download"
309 );
310 self_download(cfg, version, progress).await
311 }
312 },
313 None => self_download(cfg, version, progress).await,
314 },
315 }
316}
317
318async fn delegate_to_mise(
319 mise_bin: &Path,
320 version: &node_semver::Version,
321 progress: &dyn DownloadProgress,
322) -> Result<InstalledAube, Error> {
323 mise::install_tool_via_mise(mise_bin, "aube", version, progress).await?;
324 discover::mise_tool_installs_dir("aube")
325 .map(|d| d.join(version.to_string()))
326 .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise))
327 .ok_or_else(|| Error::MiseInstallFailed {
328 version: format!("aube@{version}"),
329 reason: "mise reported success but the install was not found — \
330 if mise uses a custom data dir, export MISE_DATA_DIR so aube sees the same path"
331 .to_string(),
332 })
333}
334
335async fn self_download(
340 cfg: &RuntimeConfig,
341 version: &node_semver::Version,
342 progress: &dyn DownloadProgress,
343) -> Result<InstalledAube, Error> {
344 let root = self_dir().ok_or_else(|| {
345 Error::io(
346 "locate the aube self dir",
347 std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"),
348 )
349 })?;
350 let dest = root.join(version.to_string());
351 let locks = root.join(".locks");
352 std::fs::create_dir_all(&locks)
353 .map_err(|e| Error::io(format!("create {}", locks.display()), e))?;
354 let lock_path = locks.join(format!("{version}.lock"));
355 let lock = tokio::task::spawn_blocking(move || xx::fslock::FSLock::new(&lock_path).lock())
356 .await
357 .map_err(|e| {
358 Error::io(
359 "acquire self-install lock",
360 std::io::Error::other(e.to_string()),
361 )
362 })?
363 .map_err(|e| {
364 Error::io(
365 "acquire self-install lock",
366 std::io::Error::other(e.to_string()),
367 )
368 })?;
369 if let Some(existing) = validate_aube_install(&dest, version.clone(), InstallOrigin::Aube) {
370 drop(lock);
371 return Ok(existing);
372 }
373
374 let triple = release_target_triple()?;
375 let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
376 let archive_name = format!("aube-v{version}-{triple}.{ext}");
377 let url = format!("{}/v{version}/{archive_name}", release_base());
378 let http = Http::new(cfg.retries);
379 progress.on_phase(Some(version), InstallPhase::Downloading);
380
381 let downloads = root.join(".downloads");
382 let staging_root = root.join(".tmp");
383 std::fs::create_dir_all(&downloads)
384 .map_err(|e| Error::io(format!("create {}", downloads.display()), e))?;
385 std::fs::create_dir_all(&staging_root)
386 .map_err(|e| Error::io(format!("create {}", staging_root.display()), e))?;
387 let archive_path = downloads.join(format!("{archive_name}.{}", std::process::id()));
388 let actual = stream_to_file(&http, &url, &archive_path, progress).await?;
389
390 progress.on_phase(Some(version), InstallPhase::Verifying);
395 let expected = match fetch_release_digest(&http, version, &archive_name).await {
396 Some(digest) => Some(digest),
397 None => fetch_published_sha256(&http, &url).await,
398 };
399 match expected {
400 Some(expected) if expected != actual => {
401 let _ = std::fs::remove_file(&archive_path);
402 drop(lock);
403 return Err(Error::ChecksumMismatch {
404 url,
405 expected: hex::encode(expected),
406 actual: hex::encode(actual),
407 });
408 }
409 Some(_) => {}
410 None => {
411 tracing::debug!(
412 %url,
413 "no asset digest or .sha256 available for this archive; trusting TLS"
414 );
415 }
416 }
417
418 progress.on_phase(Some(version), InstallPhase::Extracting);
419 let staging = staging_root.join(format!("{version}.{}", std::process::id()));
420 std::fs::create_dir_all(&staging)
421 .map_err(|e| Error::io(format!("create {}", staging.display()), e))?;
422 let extract_from = archive_path.clone();
423 let extract_to = staging.clone();
424 let zip = ext == "zip";
425 let extract_result = tokio::task::spawn_blocking(move || {
426 crate::extract::extract_archive(&extract_from, &extract_to, zip, false)
427 })
428 .await
429 .map_err(|e| Error::ExtractFailed {
430 reason: e.to_string(),
431 })?;
432 let _ = std::fs::remove_file(&archive_path);
433 if let Err(e) = extract_result {
434 let _ = std::fs::remove_dir_all(&staging);
435 drop(lock);
436 return Err(e);
437 }
438
439 if let Err(rename_err) = std::fs::rename(&staging, &dest) {
440 let _ = std::fs::remove_dir_all(&staging);
441 if validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).is_none() {
442 drop(lock);
443 return Err(Error::io(
444 format!("publish aube {} into {}", version, dest.display()),
445 rename_err,
446 ));
447 }
448 }
449 drop(lock);
450 progress.on_done();
451
452 validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).ok_or_else(|| {
453 Error::ExtractFailed {
454 reason: format!(
455 "release archive did not produce a usable aube at {}",
456 dest.display()
457 ),
458 }
459 })
460}
461
462async fn fetch_release_digest(
470 http: &Http,
471 version: &node_semver::Version,
472 archive_name: &str,
473) -> Option<[u8; 32]> {
474 let api_override = aube_util::env::embedder_env("SELF_API_BASE")
475 .and_then(|s| s.into_string().ok())
476 .filter(|s| !s.trim().is_empty())
477 .map(|s| s.trim_end_matches('/').to_string());
478 let host_override = aube_util::env::embedder_env("VERSIONS_HOST").is_some();
479 if api_override.is_none()
483 && !host_override
484 && aube_util::env::embedder_env("SELF_DOWNLOAD_BASE").is_some()
485 {
486 return None;
487 }
488
489 let host_url = format!(
491 "{}/api/github/repos/jdx/aube/releases/v{version}",
492 versions_host()
493 );
494 if let Some(digest) =
495 digest_from_release_json(http, &host_url, None, version, archive_name).await
496 {
497 return Some(digest);
498 }
499
500 let url = format!(
505 "{}/v{version}",
506 api_override.as_deref().unwrap_or(RELEASE_API_BASE)
507 );
508 let token = url
509 .starts_with("https://api.github.com/")
510 .then(|| {
511 std::env::var("GITHUB_TOKEN")
512 .or_else(|_| std::env::var("GH_TOKEN"))
513 .ok()
514 .filter(|t| !t.trim().is_empty())
515 })
516 .flatten();
517 digest_from_release_json(http, &url, token.as_deref(), version, archive_name).await
518}
519
520async fn digest_from_release_json(
525 http: &Http,
526 url: &str,
527 bearer: Option<&str>,
528 version: &node_semver::Version,
529 archive_name: &str,
530) -> Option<[u8; 32]> {
531 let resp = http
532 .get_with_bearer(url, None, None, false, bearer)
533 .await
534 .ok()?;
535 let bytes = resp.body?.bytes().await.ok()?;
536 let release: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
537 let tag = release.get("tag_name")?.as_str()?;
538 if tag != format!("v{version}") {
539 tracing::debug!(%url, tag, expected = %format!("v{version}"), "release metadata tag mismatch; ignoring");
540 return None;
541 }
542 let digest = release
543 .get("assets")?
544 .as_array()?
545 .iter()
546 .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(archive_name))?
547 .get("digest")?
548 .as_str()?;
549 parse_sha256_digest(digest)
550}
551
552fn parse_sha256_digest(digest: &str) -> Option<[u8; 32]> {
554 let hex_part = digest.strip_prefix("sha256:")?;
555 let bytes = hex::decode(hex_part).ok()?;
556 <[u8; 32]>::try_from(bytes.as_slice()).ok()
557}
558
559async fn fetch_published_sha256(http: &Http, archive_url: &str) -> Option<[u8; 32]> {
563 let url = format!("{archive_url}.sha256");
564 let resp = http.get(&url, None, None, false).await.ok()?;
565 let text = resp.body?.text().await.ok()?;
566 let hex_token = text.split_whitespace().next()?;
567 let bytes = hex::decode(hex_token).ok()?;
568 <[u8; 32]>::try_from(bytes.as_slice()).ok()
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 fn fab_aube(root: &Path, version: &str) {
576 let dir = root.join(version);
577 std::fs::create_dir_all(&dir).unwrap();
578 for bin in ["aube", "aubr", "aubx"] {
579 let path = dir.join(if cfg!(windows) {
580 format!("{bin}.exe")
581 } else {
582 bin.to_string()
583 });
584 std::fs::write(&path, "#!/bin/sh\necho fake\n").unwrap();
585 #[cfg(unix)]
586 {
587 use std::os::unix::fs::PermissionsExt;
588 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
589 }
590 }
591 }
592
593 #[test]
594 fn scans_and_validates_aube_installs() {
595 let tmp = tempfile::tempdir().unwrap();
596 fab_aube(tmp.path(), "1.17.0");
597 fab_aube(tmp.path(), "1.18.2");
598 fab_aube(tmp.path(), "1.19.0");
599 std::fs::write(tmp.path().join("1.19.0/incomplete"), "").unwrap();
600 std::fs::create_dir_all(tmp.path().join("not-a-version")).unwrap();
601
602 let mut versions: Vec<String> = scan_aube_dir(tmp.path(), InstallOrigin::Mise)
603 .into_iter()
604 .map(|i| i.version.to_string())
605 .collect();
606 versions.sort();
607 assert_eq!(versions, vec!["1.17.0", "1.18.2"]);
608 }
609
610 #[test]
611 fn validate_accepts_bin_subdir_layout() {
612 let tmp = tempfile::tempdir().unwrap();
613 let dir = tmp.path().join("2.0.0/bin");
614 std::fs::create_dir_all(&dir).unwrap();
615 let exe = dir.join(if cfg!(windows) { "aube.exe" } else { "aube" });
616 std::fs::write(&exe, "x").unwrap();
617 #[cfg(unix)]
618 {
619 use std::os::unix::fs::PermissionsExt;
620 std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
621 }
622 let install = validate_aube_install(
623 &tmp.path().join("2.0.0"),
624 "2.0.0".parse().unwrap(),
625 InstallOrigin::Aube,
626 )
627 .unwrap();
628 assert!(install.exe.parent().unwrap().ends_with("bin"));
629 }
630
631 #[test]
632 fn parses_github_digest_form() {
633 let digest = format!("sha256:{}", "ab".repeat(32));
634 assert_eq!(parse_sha256_digest(&digest), Some([0xab; 32]));
635 assert_eq!(parse_sha256_digest("sha512:abcd"), None);
636 assert_eq!(parse_sha256_digest("sha256:nothex"), None);
637 assert_eq!(parse_sha256_digest("sha256:abcd"), None); }
639
640 #[test]
641 fn target_triple_is_publishable() {
642 match release_target_triple() {
646 Ok(t) => {
647 assert!(
648 t.contains("apple-darwin")
649 || t.contains("unknown-linux")
650 || t.contains("pc-windows"),
651 "{t}"
652 );
653 }
654 Err(Error::NoReleaseBuild { .. }) => {
655 let os = std::env::consts::OS;
656 assert!(
657 os == "freebsd" || (os == "macos" && std::env::consts::ARCH == "x86_64"),
658 "unexpected unsupported host: {os}-{}",
659 std::env::consts::ARCH
660 );
661 }
662 Err(other) => panic!("unexpected error: {other}"),
663 }
664 }
665}