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> {
162 let arch = match std::env::consts::ARCH {
163 "x86_64" => "x86_64",
164 "aarch64" => "aarch64",
165 other => {
166 return Err(Error::UnsupportedPlatform {
167 platform: format!("{}-{other}", std::env::consts::OS),
168 });
169 }
170 };
171 let triple = match std::env::consts::OS {
172 "macos" => {
173 if arch != "aarch64" {
174 return Err(Error::UnsupportedPlatform {
175 platform: "macos-x86_64 (no published aube build; install via mise)"
176 .to_string(),
177 });
178 }
179 format!("{arch}-apple-darwin")
180 }
181 "linux" => {
182 let libc = if crate::Platform::current()?.libc.as_deref() == Some("musl") {
183 "musl"
184 } else {
185 "gnu"
186 };
187 format!("{arch}-unknown-linux-{libc}")
188 }
189 "windows" => format!("{arch}-pc-windows-msvc"),
190 other => {
191 return Err(Error::UnsupportedPlatform {
192 platform: format!("{other}-{arch}"),
193 });
194 }
195 };
196 Ok(triple)
197}
198
199fn release_base() -> String {
200 aube_util::env::embedder_env("SELF_DOWNLOAD_BASE")
201 .and_then(|s| s.into_string().ok())
202 .filter(|s| !s.trim().is_empty())
203 .map(|s| s.trim_end_matches('/').to_string())
204 .unwrap_or_else(|| RELEASE_BASE.to_string())
205}
206
207fn versions_host() -> String {
208 aube_util::env::embedder_env("VERSIONS_HOST")
209 .and_then(|s| s.into_string().ok())
210 .filter(|s| !s.trim().is_empty())
211 .map(|s| s.trim_end_matches('/').to_string())
212 .unwrap_or_else(|| VERSIONS_HOST.to_string())
213}
214
215pub async fn available_aube_versions(retries: u32) -> Result<Vec<node_semver::Version>, Error> {
221 let http = Http::new(retries);
222 let list_url = format!("{}/aube", versions_host());
223 match fetch_text(&http, &list_url).await {
224 Ok(text) => {
225 let versions: Vec<node_semver::Version> = text
226 .lines()
227 .filter_map(|l| node_semver::Version::parse(l.trim().trim_start_matches('v')).ok())
228 .collect();
229 if !versions.is_empty() {
230 return Ok(versions);
231 }
232 tracing::debug!(%list_url, "versions host returned an empty list; falling back");
233 }
234 Err(e) => {
235 tracing::debug!(%list_url, error = %e, "versions host unreachable; falling back");
236 }
237 }
238 let url = aube_util::env::embedder_env("SELF_VERSION_URL")
239 .and_then(|s| s.into_string().ok())
240 .filter(|s| !s.trim().is_empty())
241 .unwrap_or_else(|| VERSION_URL.to_string());
242 let text = fetch_text(&http, &url).await?;
243 let latest = node_semver::Version::parse(text.trim().trim_start_matches('v')).map_err(|e| {
244 Error::DownloadFailed {
245 url,
246 reason: format!("unparseable version announcement: {e}"),
247 }
248 })?;
249 Ok(vec![latest])
250}
251
252async fn fetch_text(http: &Http, url: &str) -> Result<String, Error> {
253 let resp = http.get(url, None, None, false).await?;
254 let body = resp.body.ok_or_else(|| Error::DownloadFailed {
255 url: url.to_string(),
256 reason: "unexpected empty response".to_string(),
257 })?;
258 body.text().await.map_err(|e| Error::DownloadFailed {
259 url: url.to_string(),
260 reason: e.to_string(),
261 })
262}
263
264pub async fn install_aube(
268 cfg: &RuntimeConfig,
269 version: &node_semver::Version,
270 progress: &dyn DownloadProgress,
271) -> Result<InstalledAube, Error> {
272 if let Some(existing) = find_installed_aube(version) {
273 return Ok(existing);
274 }
275 match cfg.installer {
276 InstallerMode::Aube => self_download(cfg, version, progress).await,
277 InstallerMode::Mise => {
278 let Some(mise_bin) = mise::mise_on_path() else {
279 return Err(Error::MiseInstallFailed {
280 version: format!("aube@{version}"),
281 reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
282 });
283 };
284 delegate_to_mise(&mise_bin, version, progress).await
285 }
286 InstallerMode::Auto => match mise::mise_on_path() {
287 Some(mise_bin) => match delegate_to_mise(&mise_bin, version, progress).await {
288 Ok(install) => Ok(install),
289 Err(e) => {
290 tracing::warn!(
291 code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
292 error = %e,
293 "mise failed to install aube; falling back to a release download"
294 );
295 self_download(cfg, version, progress).await
296 }
297 },
298 None => self_download(cfg, version, progress).await,
299 },
300 }
301}
302
303async fn delegate_to_mise(
304 mise_bin: &Path,
305 version: &node_semver::Version,
306 progress: &dyn DownloadProgress,
307) -> Result<InstalledAube, Error> {
308 mise::install_tool_via_mise(mise_bin, "aube", version, progress).await?;
309 discover::mise_tool_installs_dir("aube")
310 .map(|d| d.join(version.to_string()))
311 .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise))
312 .ok_or_else(|| Error::MiseInstallFailed {
313 version: format!("aube@{version}"),
314 reason: "mise reported success but the install was not found — \
315 if mise uses a custom data dir, export MISE_DATA_DIR so aube sees the same path"
316 .to_string(),
317 })
318}
319
320async fn self_download(
325 cfg: &RuntimeConfig,
326 version: &node_semver::Version,
327 progress: &dyn DownloadProgress,
328) -> Result<InstalledAube, Error> {
329 let root = self_dir().ok_or_else(|| {
330 Error::io(
331 "locate the aube self dir",
332 std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"),
333 )
334 })?;
335 let dest = root.join(version.to_string());
336 let locks = root.join(".locks");
337 std::fs::create_dir_all(&locks)
338 .map_err(|e| Error::io(format!("create {}", locks.display()), e))?;
339 let lock_path = locks.join(format!("{version}.lock"));
340 let lock = tokio::task::spawn_blocking(move || xx::fslock::FSLock::new(&lock_path).lock())
341 .await
342 .map_err(|e| {
343 Error::io(
344 "acquire self-install lock",
345 std::io::Error::other(e.to_string()),
346 )
347 })?
348 .map_err(|e| {
349 Error::io(
350 "acquire self-install lock",
351 std::io::Error::other(e.to_string()),
352 )
353 })?;
354 if let Some(existing) = validate_aube_install(&dest, version.clone(), InstallOrigin::Aube) {
355 drop(lock);
356 return Ok(existing);
357 }
358
359 let triple = release_target_triple()?;
360 let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
361 let archive_name = format!("aube-v{version}-{triple}.{ext}");
362 let url = format!("{}/v{version}/{archive_name}", release_base());
363 let http = Http::new(cfg.retries);
364 progress.on_phase(Some(version), InstallPhase::Downloading);
365
366 let downloads = root.join(".downloads");
367 let staging_root = root.join(".tmp");
368 std::fs::create_dir_all(&downloads)
369 .map_err(|e| Error::io(format!("create {}", downloads.display()), e))?;
370 std::fs::create_dir_all(&staging_root)
371 .map_err(|e| Error::io(format!("create {}", staging_root.display()), e))?;
372 let archive_path = downloads.join(format!("{archive_name}.{}", std::process::id()));
373 let actual = stream_to_file(&http, &url, &archive_path, progress).await?;
374
375 progress.on_phase(Some(version), InstallPhase::Verifying);
380 let expected = match fetch_release_digest(&http, version, &archive_name).await {
381 Some(digest) => Some(digest),
382 None => fetch_published_sha256(&http, &url).await,
383 };
384 match expected {
385 Some(expected) if expected != actual => {
386 let _ = std::fs::remove_file(&archive_path);
387 drop(lock);
388 return Err(Error::ChecksumMismatch {
389 url,
390 expected: hex::encode(expected),
391 actual: hex::encode(actual),
392 });
393 }
394 Some(_) => {}
395 None => {
396 tracing::debug!(
397 %url,
398 "no asset digest or .sha256 available for this archive; trusting TLS"
399 );
400 }
401 }
402
403 progress.on_phase(Some(version), InstallPhase::Extracting);
404 let staging = staging_root.join(format!("{version}.{}", std::process::id()));
405 std::fs::create_dir_all(&staging)
406 .map_err(|e| Error::io(format!("create {}", staging.display()), e))?;
407 let extract_from = archive_path.clone();
408 let extract_to = staging.clone();
409 let zip = ext == "zip";
410 let extract_result = tokio::task::spawn_blocking(move || {
411 crate::extract::extract_archive(&extract_from, &extract_to, zip, false)
412 })
413 .await
414 .map_err(|e| Error::ExtractFailed {
415 reason: e.to_string(),
416 })?;
417 let _ = std::fs::remove_file(&archive_path);
418 if let Err(e) = extract_result {
419 let _ = std::fs::remove_dir_all(&staging);
420 drop(lock);
421 return Err(e);
422 }
423
424 if let Err(rename_err) = std::fs::rename(&staging, &dest) {
425 let _ = std::fs::remove_dir_all(&staging);
426 if validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).is_none() {
427 drop(lock);
428 return Err(Error::io(
429 format!("publish aube {} into {}", version, dest.display()),
430 rename_err,
431 ));
432 }
433 }
434 drop(lock);
435 progress.on_done();
436
437 validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).ok_or_else(|| {
438 Error::ExtractFailed {
439 reason: format!(
440 "release archive did not produce a usable aube at {}",
441 dest.display()
442 ),
443 }
444 })
445}
446
447async fn fetch_release_digest(
455 http: &Http,
456 version: &node_semver::Version,
457 archive_name: &str,
458) -> Option<[u8; 32]> {
459 let api_override = aube_util::env::embedder_env("SELF_API_BASE")
460 .and_then(|s| s.into_string().ok())
461 .filter(|s| !s.trim().is_empty())
462 .map(|s| s.trim_end_matches('/').to_string());
463 let host_override = aube_util::env::embedder_env("VERSIONS_HOST").is_some();
464 if api_override.is_none()
468 && !host_override
469 && aube_util::env::embedder_env("SELF_DOWNLOAD_BASE").is_some()
470 {
471 return None;
472 }
473
474 let host_url = format!(
476 "{}/api/github/repos/jdx/aube/releases/v{version}",
477 versions_host()
478 );
479 if let Some(digest) =
480 digest_from_release_json(http, &host_url, None, version, archive_name).await
481 {
482 return Some(digest);
483 }
484
485 let url = format!(
490 "{}/v{version}",
491 api_override.as_deref().unwrap_or(RELEASE_API_BASE)
492 );
493 let token = url
494 .starts_with("https://api.github.com/")
495 .then(|| {
496 std::env::var("GITHUB_TOKEN")
497 .or_else(|_| std::env::var("GH_TOKEN"))
498 .ok()
499 .filter(|t| !t.trim().is_empty())
500 })
501 .flatten();
502 digest_from_release_json(http, &url, token.as_deref(), version, archive_name).await
503}
504
505async fn digest_from_release_json(
510 http: &Http,
511 url: &str,
512 bearer: Option<&str>,
513 version: &node_semver::Version,
514 archive_name: &str,
515) -> Option<[u8; 32]> {
516 let resp = http
517 .get_with_bearer(url, None, None, false, bearer)
518 .await
519 .ok()?;
520 let bytes = resp.body?.bytes().await.ok()?;
521 let release: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
522 let tag = release.get("tag_name")?.as_str()?;
523 if tag != format!("v{version}") {
524 tracing::debug!(%url, tag, expected = %format!("v{version}"), "release metadata tag mismatch; ignoring");
525 return None;
526 }
527 let digest = release
528 .get("assets")?
529 .as_array()?
530 .iter()
531 .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(archive_name))?
532 .get("digest")?
533 .as_str()?;
534 parse_sha256_digest(digest)
535}
536
537fn parse_sha256_digest(digest: &str) -> Option<[u8; 32]> {
539 let hex_part = digest.strip_prefix("sha256:")?;
540 let bytes = hex::decode(hex_part).ok()?;
541 <[u8; 32]>::try_from(bytes.as_slice()).ok()
542}
543
544async fn fetch_published_sha256(http: &Http, archive_url: &str) -> Option<[u8; 32]> {
548 let url = format!("{archive_url}.sha256");
549 let resp = http.get(&url, None, None, false).await.ok()?;
550 let text = resp.body?.text().await.ok()?;
551 let hex_token = text.split_whitespace().next()?;
552 let bytes = hex::decode(hex_token).ok()?;
553 <[u8; 32]>::try_from(bytes.as_slice()).ok()
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 fn fab_aube(root: &Path, version: &str) {
561 let dir = root.join(version);
562 std::fs::create_dir_all(&dir).unwrap();
563 for bin in ["aube", "aubr", "aubx"] {
564 let path = dir.join(if cfg!(windows) {
565 format!("{bin}.exe")
566 } else {
567 bin.to_string()
568 });
569 std::fs::write(&path, "#!/bin/sh\necho fake\n").unwrap();
570 #[cfg(unix)]
571 {
572 use std::os::unix::fs::PermissionsExt;
573 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
574 }
575 }
576 }
577
578 #[test]
579 fn scans_and_validates_aube_installs() {
580 let tmp = tempfile::tempdir().unwrap();
581 fab_aube(tmp.path(), "1.17.0");
582 fab_aube(tmp.path(), "1.18.2");
583 fab_aube(tmp.path(), "1.19.0");
584 std::fs::write(tmp.path().join("1.19.0/incomplete"), "").unwrap();
585 std::fs::create_dir_all(tmp.path().join("not-a-version")).unwrap();
586
587 let mut versions: Vec<String> = scan_aube_dir(tmp.path(), InstallOrigin::Mise)
588 .into_iter()
589 .map(|i| i.version.to_string())
590 .collect();
591 versions.sort();
592 assert_eq!(versions, vec!["1.17.0", "1.18.2"]);
593 }
594
595 #[test]
596 fn validate_accepts_bin_subdir_layout() {
597 let tmp = tempfile::tempdir().unwrap();
598 let dir = tmp.path().join("2.0.0/bin");
599 std::fs::create_dir_all(&dir).unwrap();
600 let exe = dir.join(if cfg!(windows) { "aube.exe" } else { "aube" });
601 std::fs::write(&exe, "x").unwrap();
602 #[cfg(unix)]
603 {
604 use std::os::unix::fs::PermissionsExt;
605 std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
606 }
607 let install = validate_aube_install(
608 &tmp.path().join("2.0.0"),
609 "2.0.0".parse().unwrap(),
610 InstallOrigin::Aube,
611 )
612 .unwrap();
613 assert!(install.exe.parent().unwrap().ends_with("bin"));
614 }
615
616 #[test]
617 fn parses_github_digest_form() {
618 let digest = format!("sha256:{}", "ab".repeat(32));
619 assert_eq!(parse_sha256_digest(&digest), Some([0xab; 32]));
620 assert_eq!(parse_sha256_digest("sha512:abcd"), None);
621 assert_eq!(parse_sha256_digest("sha256:nothex"), None);
622 assert_eq!(parse_sha256_digest("sha256:abcd"), None); }
624
625 #[test]
626 fn target_triple_is_publishable() {
627 match release_target_triple() {
631 Ok(t) => {
632 assert!(
633 t.contains("apple-darwin")
634 || t.contains("unknown-linux")
635 || t.contains("pc-windows"),
636 "{t}"
637 );
638 }
639 Err(Error::UnsupportedPlatform { .. }) => {
640 assert_eq!(std::env::consts::OS, "macos");
641 assert_eq!(std::env::consts::ARCH, "x86_64");
642 }
643 Err(other) => panic!("unexpected error: {other}"),
644 }
645 }
646}