diff --git a/Cargo.lock b/Cargo.lock index 71df1dfb..63ae761a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.11" @@ -158,6 +169,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bindgen" version = "0.66.1" @@ -203,7 +220,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.0", ] [[package]] @@ -237,12 +254,39 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "cc" version = "1.0.90" @@ -308,6 +352,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.7.0" @@ -431,6 +485,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "constant_time_eq" version = "0.3.0" @@ -631,6 +691,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -967,6 +1028,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.9" @@ -1191,6 +1261,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1603,6 +1682,7 @@ dependencies = [ "uuid", "walkdir", "which 6.0.1", + "zip-extract", ] [[package]] @@ -1731,6 +1811,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "path-clean" version = "1.0.1" @@ -1743,6 +1834,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -2260,6 +2363,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -2416,6 +2530,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -3284,3 +3404,62 @@ dependencies = [ "quote", "syn 2.0.57", ] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "zstd", +] + +[[package]] +name = "zip-extract" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e109e5a291403b4c1e514d39f8a22d3f98d257e691a52bb1f16051bb1ffed63e" +dependencies = [ + "log", + "thiserror", + "zip", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.10+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index b4e1fdae..1b1eed73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,3 +74,4 @@ url = "2.5.0" uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } walkdir = "2.4.0" which = "6.0.1" +zip-extract = "0.1.3" diff --git a/src/internal/cache/github_release.rs b/src/internal/cache/github_release.rs new file mode 100644 index 00000000..1001fdfe --- /dev/null +++ b/src/internal/cache/github_release.rs @@ -0,0 +1,289 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::io; +use std::str::FromStr; + +use lazy_static::lazy_static; +use node_semver::Version as semverVersion; +use serde::Deserialize; +use serde::Serialize; +use time::OffsetDateTime; + +use crate::internal::cache::handler::exclusive; +use crate::internal::cache::handler::shared; +use crate::internal::cache::loaders::get_github_release_operation_cache; +use crate::internal::cache::loaders::set_github_release_operation_cache; +use crate::internal::cache::utils; +use crate::internal::cache::utils::Empty; +use crate::internal::cache::CacheObject; +use crate::internal::config::up::asdf_base::version_match; +use crate::internal::self_updater::compatible_release_arch; +use crate::internal::self_updater::compatible_release_os; + +const GITHUB_RELEASE_CACHE_NAME: &str = "github_release_operation"; + +lazy_static! { + static ref GITHUB_RELEASE_OPERATION_NOW: OffsetDateTime = OffsetDateTime::now_utc(); +} + +// TODO: merge this with homebrew_operation_now, maybe up_operation_now? +fn github_release_operation_now() -> OffsetDateTime { + *GITHUB_RELEASE_OPERATION_NOW +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubReleaseOperationCache { + #[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")] + pub installed: Vec, + #[serde(default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty")] + pub releases: BTreeMap, + #[serde( + default = "utils::origin_of_time", + with = "time::serde::rfc3339", + skip_serializing_if = "utils::is_origin_of_time" + )] + pub updated_at: OffsetDateTime, +} + +impl GithubReleaseOperationCache { + pub fn updated(&mut self) { + self.updated_at = OffsetDateTime::now_utc(); + } + + pub fn add_releases(&mut self, repository: &str, releases: &GithubReleases) -> bool { + self.releases + .insert(repository.to_string(), releases.clone()); + self.updated(); + true + } + + pub fn get_releases(&self, repository: &str) -> Option<&GithubReleases> { + self.releases.get(repository) + } + + pub fn add_installed(&mut self, workdir_id: &str, repository: &str, version: &str) -> bool { + let inserted = if let Some(install) = self + .installed + .iter_mut() + .find(|i| i.repository == repository && i.version == version) + { + if install.required_by.insert(workdir_id.to_string()) + || install.last_required_at < github_release_operation_now() + { + install.last_required_at = github_release_operation_now(); + true + } else { + false + } + } else { + let install = GithubReleaseInstalled { + repository: repository.to_string(), + version: version.to_string(), + required_by: [workdir_id.to_string()].iter().cloned().collect(), + last_required_at: github_release_operation_now(), + }; + self.installed.push(install); + true + }; + + if inserted { + self.updated(); + } + + inserted + } +} + +impl Empty for GithubReleaseOperationCache { + fn is_empty(&self) -> bool { + self.installed.is_empty() && self.releases.is_empty() + } +} + +impl CacheObject for GithubReleaseOperationCache { + fn new_empty() -> Self { + Self { + installed: Vec::new(), + releases: BTreeMap::new(), + updated_at: utils::origin_of_time(), + } + } + + fn get() -> Self { + get_github_release_operation_cache() + } + + fn shared() -> io::Result { + shared::(GITHUB_RELEASE_CACHE_NAME) + } + + fn exclusive(processing_fn: F) -> io::Result + where + F: FnOnce(&mut Self) -> bool, + { + exclusive::( + GITHUB_RELEASE_CACHE_NAME, + processing_fn, + set_github_release_operation_cache, + ) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubReleaseInstalled { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub repository: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub version: String, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub required_by: BTreeSet, + #[serde(default = "utils::origin_of_time", with = "time::serde::rfc3339")] + pub last_required_at: OffsetDateTime, +} + +impl GithubReleaseInstalled { + pub fn stale(&self) -> bool { + self.last_required_at < github_release_operation_now() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubReleases { + pub releases: Vec, + #[serde(default = "OffsetDateTime::now_utc", with = "time::serde::rfc3339")] + pub fetched_at: OffsetDateTime, +} + +impl GithubReleases { + pub fn from_json(json: &str) -> Result { + let releases: Vec = match serde_json::from_str(json) { + Ok(releases) => releases, + Err(err) => return Err(format!("failed to parse releases: {}", err)), + }; + + Ok(Self { + releases, + fetched_at: OffsetDateTime::now_utc(), + }) + } + + pub fn is_stale(&self, ttl: u64) -> bool { + let duration = time::Duration::seconds(ttl as i64); + self.fetched_at + duration < OffsetDateTime::now_utc() + } + + pub fn get( + &self, + version: &str, + prerelease: bool, + ) -> Option<(semverVersion, GithubReleaseVersion)> { + self.releases + .iter() + .filter_map(|release| { + // Discard drafts as they are not considered releases + if release.draft { + return None; + } + + // Discard pre-releases if needed + if !prerelease && release.prerelease { + return None; + } + + // Parse the version + let release_version = match release.version() { + Ok(release_version) => release_version, + Err(_) => { + return None; + } + }; + + // Make sure the version fits the requested version + if !version_match(version, &release_version.to_string(), prerelease) { + return None; + } + + // Check that we have one matching asset for the current + // platform and architecture, that is either a .zip or .tar.gz + let assets = release + .assets + .iter() + .filter(|asset| { + let is_tgz = + asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz"); + let is_zip = asset.name.ends_with(".zip"); + + if !is_tgz && !is_zip { + return false; + } + + let asset_name = asset.name.to_lowercase(); + + let compatible_release_os = compatible_release_os(); + if compatible_release_os + .iter() + .all(|os| !asset_name.contains(os)) + { + return false; + } + + let compatible_release_arch = compatible_release_arch(); + if compatible_release_arch + .iter() + .all(|arch| !asset_name.contains(arch)) + { + return false; + } + + true + }) + .cloned() + .collect::>(); + + if assets.is_empty() { + return None; + } + + let release = GithubReleaseVersion { + tag_name: release.tag_name.clone(), + name: release.name.clone(), + draft: release.draft, + prerelease: release.prerelease, + assets, + }; + + Some((release_version, release)) + }) + .max_by(|(version_a, _), (version_b, _)| version_a.cmp(version_b)) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubReleaseVersion { + pub tag_name: String, + pub name: String, + pub draft: bool, + pub prerelease: bool, + pub assets: Vec, +} + +impl GithubReleaseVersion { + pub fn version(&self) -> Result { + // Get the version as the tag name but ideally without the first v + let version = match self.tag_name.strip_prefix('v') { + Some(version) => version, + None => &self.tag_name, + }; + + semverVersion::from_str(version).map_err(|err| format!("failed to parse version: {}", err)) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub state: String, + pub content_type: String, + pub size: u64, +} diff --git a/src/internal/cache/loaders.rs b/src/internal/cache/loaders.rs index 9931c435..d50e2541 100644 --- a/src/internal/cache/loaders.rs +++ b/src/internal/cache/loaders.rs @@ -4,6 +4,7 @@ use lazy_static::lazy_static; use crate::internal::cache::AsdfOperationCache; use crate::internal::cache::CacheObject; +use crate::internal::cache::GithubReleaseOperationCache; use crate::internal::cache::HomebrewOperationCache; use crate::internal::cache::OmniPathCache; use crate::internal::cache::PromptsCache; @@ -13,6 +14,8 @@ use crate::internal::cache::UpEnvironmentsCache; lazy_static! { static ref ASDF_OPERATION_CACHE: Mutex = Mutex::new(AsdfOperationCache::new_load()); + static ref GITHUB_RELEASES_OPERATION_CACHE: Mutex = + Mutex::new(GithubReleaseOperationCache::new_load()); static ref HOMEBREW_OPERATION_CACHE: Mutex = Mutex::new(HomebrewOperationCache::new_load()); static ref OMNIPATH_CACHE: Mutex = Mutex::new(OmniPathCache::new_load()); @@ -35,6 +38,10 @@ pub fn get_asdf_operation_cache() -> AsdfOperationCache { generic_get_cache(&ASDF_OPERATION_CACHE) } +pub fn get_github_release_operation_cache() -> GithubReleaseOperationCache { + generic_get_cache(&GITHUB_RELEASES_OPERATION_CACHE) +} + pub fn get_homebrew_operation_cache() -> HomebrewOperationCache { generic_get_cache(&HOMEBREW_OPERATION_CACHE) } @@ -67,6 +74,10 @@ pub fn set_asdf_operation_cache(cache_set: AsdfOperationCache) { generic_set_cache(&ASDF_OPERATION_CACHE, cache_set); } +pub fn set_github_release_operation_cache(cache_set: GithubReleaseOperationCache) { + generic_set_cache(&GITHUB_RELEASES_OPERATION_CACHE, cache_set); +} + pub fn set_homebrew_operation_cache(cache_set: HomebrewOperationCache) { generic_set_cache(&HOMEBREW_OPERATION_CACHE, cache_set); } diff --git a/src/internal/cache/mod.rs b/src/internal/cache/mod.rs index 645d2afd..c69126d6 100644 --- a/src/internal/cache/mod.rs +++ b/src/internal/cache/mod.rs @@ -3,6 +3,11 @@ pub(crate) mod loaders; pub(crate) mod asdf_operation; pub(crate) use asdf_operation::AsdfOperationCache; +pub(crate) mod github_release; +pub(crate) use github_release::GithubReleaseOperationCache; +pub(crate) use github_release::GithubReleaseVersion; +pub(crate) use github_release::GithubReleases; + pub(crate) mod handler; pub(crate) mod homebrew_operation; diff --git a/src/internal/commands/builtin/up.rs b/src/internal/commands/builtin/up.rs index 1d09f56b..0e00d317 100644 --- a/src/internal/commands/builtin/up.rs +++ b/src/internal/commands/builtin/up.rs @@ -25,11 +25,11 @@ use crate::internal::commands::Command; use crate::internal::config::config; use crate::internal::config::flush_config; use crate::internal::config::global_config; -use crate::internal::config::up::run_progress; +use crate::internal::config::up::utils::run_progress; use crate::internal::config::up::utils::PrintProgressHandler; +use crate::internal::config::up::utils::ProgressHandler; use crate::internal::config::up::utils::RunConfig; -use crate::internal::config::up::ProgressHandler; -use crate::internal::config::up::SpinnerProgressHandler; +use crate::internal::config::up::utils::SpinnerProgressHandler; use crate::internal::config::up::UpConfig; use crate::internal::config::up::UpOptions; use crate::internal::config::CommandSyntax; diff --git a/src/internal/config/parser/cache/root.rs b/src/internal/config/parser/cache/root.rs index 4158ee1d..c467d107 100644 --- a/src/internal/config/parser/cache/root.rs +++ b/src/internal/config/parser/cache/root.rs @@ -3,6 +3,7 @@ use serde::Serialize; use crate::internal::config::parser::cache::AsdfCacheConfig; use crate::internal::config::parser::cache::HomebrewCacheConfig; +use crate::internal::config::utils::parse_duration_or_default; use crate::internal::config::ConfigValue; use crate::internal::env::cache_home; @@ -10,6 +11,7 @@ use crate::internal::env::cache_home; pub struct CacheConfig { pub path: String, pub asdf: AsdfCacheConfig, + pub github_release_versions_expire: u64, pub homebrew: HomebrewCacheConfig, } @@ -18,12 +20,15 @@ impl Default for CacheConfig { Self { path: cache_home(), asdf: AsdfCacheConfig::default(), + github_release_versions_expire: Self::DEFAULT_GITHUB_RELEASE_VERSIONS_EXPIRE, homebrew: HomebrewCacheConfig::default(), } } } impl CacheConfig { + const DEFAULT_GITHUB_RELEASE_VERSIONS_EXPIRE: u64 = 86400; // 1 day + pub fn from_config_value(config_value: Option) -> Self { let config_value = match config_value { Some(config_value) => config_value, @@ -37,11 +42,17 @@ impl CacheConfig { let asdf = AsdfCacheConfig::from_config_value(config_value.get("asdf")); + let github_release_versions_expire = parse_duration_or_default( + config_value.get("github_release_versions_expire").as_ref(), + Self::DEFAULT_GITHUB_RELEASE_VERSIONS_EXPIRE, + ); + let homebrew = HomebrewCacheConfig::from_config_value(config_value.get("homebrew")); Self { path, asdf, + github_release_versions_expire, homebrew, } } diff --git a/src/internal/config/up/asdf_base.rs b/src/internal/config/up/asdf_base.rs index ce4657ed..39cc5ee8 100644 --- a/src/internal/config/up/asdf_base.rs +++ b/src/internal/config/up/asdf_base.rs @@ -698,7 +698,7 @@ impl UpConfigAsdfBase { let mut version = "".to_string(); for available_version in available_versions { - if version_match(&self.version, available_version.as_str()) { + if version_match(&self.version, available_version.as_str(), false) { version = available_version; } } @@ -1018,15 +1018,20 @@ impl UpConfigAsdfBase { } } -fn version_match(expect: &str, version: &str) -> bool { +pub fn version_match(expect: &str, version: &str, prerelease: bool) -> bool { if expect == "latest" { let mut prev = '.'; - for c in version.chars() { + let chars = version.chars().collect::>(); + let lastidx = chars.len() - 1; + for (idx, c) in chars.iter().enumerate() { + let c = *c; if !c.is_ascii_digit() { if c == '.' { if prev == '.' { return false; } + } else if c == '-' { + return prerelease && idx != lastidx && chars[idx + 1].is_alphanumeric(); } else { return false; } @@ -1036,6 +1041,10 @@ fn version_match(expect: &str, version: &str) -> bool { return true; } + if expect == version { + return true; + } + if let Ok(requirements) = semverRange::from_str(expect) { if let Ok(version) = semverVersion::from_str(version) { // By not directly returning, we allow to keep the prefix @@ -1047,12 +1056,13 @@ fn version_match(expect: &str, version: &str) -> bool { } let expect_prefix = format!("{}.", expect); - if !version.starts_with(&expect_prefix) { - return false; + if let Some(rest_of_line) = version.strip_prefix(&expect_prefix) { + return rest_of_line + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || (prerelease && c == '-')); } - let rest_of_line = version.strip_prefix(&expect_prefix).unwrap(); - rest_of_line.chars().all(|c| c.is_ascii_digit() || c == '.') + false } fn detect_version_from_asdf_version_file(tool_name: String, path: PathBuf) -> Option { diff --git a/src/internal/config/up/base.rs b/src/internal/config/up/base.rs index cec332f5..4ce0afe7 100644 --- a/src/internal/config/up/base.rs +++ b/src/internal/config/up/base.rs @@ -1,4 +1,3 @@ -use itertools::any; use itertools::Itertools; use serde::Deserialize; use serde::Serialize; @@ -6,10 +5,11 @@ use serde::Serialize; use crate::internal::cache::utils::Empty; use crate::internal::cache::CacheObject; use crate::internal::cache::UpEnvironmentsCache; -use crate::internal::config::up::utils::force_remove_dir_all; +use crate::internal::config::up::utils::cleanup_path; use crate::internal::config::up::utils::ProgressHandler; use crate::internal::config::up::utils::UpProgressHandler; use crate::internal::config::up::UpConfigAsdfBase; +use crate::internal::config::up::UpConfigGithubRelease; use crate::internal::config::up::UpConfigHomebrew; use crate::internal::config::up::UpConfigTool; use crate::internal::config::up::UpError; @@ -200,7 +200,7 @@ impl UpConfig { /// upgraded tools. pub fn cleanup(&self, progress: Option<(usize, usize)>) -> Result<(), UpError> { let progress_handler = UpProgressHandler::new(progress); - progress_handler.init("resources cleanup".light_blue()); + progress_handler.init("resources cleanup:".light_blue()); let mut cleanups = vec![]; @@ -211,6 +211,9 @@ impl UpConfig { if let Some(cleanup) = UpConfigHomebrew::cleanup(&progress_handler)? { cleanups.push(cleanup); } + if let Some(cleanup) = UpConfigGithubRelease::cleanup(&progress_handler)? { + cleanups.push(cleanup); + } // Then cleanup the data path if let Some(cleanup) = self.cleanup_data_path(&progress_handler)? { @@ -251,99 +254,14 @@ impl UpConfig { .dedup() .collect::>(); - // If there are no expected data paths, we can remove the workdir - // data path entirely - if expected_data_paths.is_empty() { - force_remove_dir_all(wd_data_path).map_err(|err| { - UpError::Exec(format!( - "failed to remove workdir data path {}: {}", - wd_data_path.display(), - err - )) - })?; - - return Ok(Some("removed workdir data path".to_string())); - } - - // If there are expected paths, we want to do a breadth-first search - // so that we can remove paths fast when they are not expected; we - // can stop in depth when we find a path that is expected (since it - // means that any deeper path is also expected) - let mut known_unknown_paths = vec![]; - let mut num_removed = 0; - for entry in walkdir::WalkDir::new(wd_data_path) - .into_iter() - .filter_entry(|e| { - // If the path is the root, we want to keep it - if e.path() == wd_data_path { - return true; - } - - // Check if the path is known, in which case we can skip it - // and its children - if any(expected_data_paths.iter(), |expected_data_path| { - e.path() == *expected_data_path - }) { - return false; - } - - // If we're here, the path is not known, but we want to keep - // digging if it is the beginning of a known path; we will need - // to filter those paths out after - if any(expected_data_paths.iter(), |expected_data_path| { - expected_data_path.starts_with(e.path()) - }) { - return true; - } + let (root_removed, num_removed, _) = + cleanup_path(wd_data_path, expected_data_paths, progress_handler, true)?; - // If we're here, the path is not known and is not the beginning - // of a known path, so we want to keep it as it will need to get - // removed; however, we don't want to dig indefinitely, so we will - // keep track of paths that we already marked as unknown, so we - // can skip their children - if any(known_unknown_paths.iter(), |unknown_path| { - e.path().starts_with(unknown_path) - }) { - return false; - } - - // If we're here, the path is not known and is not the beginning - // of a known path, so we want to keep it as it will need to get - known_unknown_paths.push(e.path().to_path_buf()); - true - }) - .filter_map(|e| e.ok()) - // Filter the parents of known paths since we don't want to remove them - .filter(|e| { - !any(expected_data_paths.iter(), |expected_data_path| { - expected_data_path.starts_with(e.path()) - }) - }) - { - let path = entry.path(); - - progress_handler.progress(format!("removing {}", path.display())); - - if path.is_file() { - if let Err(error) = std::fs::remove_file(path) { - return Err(UpError::Exec(format!( - "failed to remove {}: {}", - path.display(), - error - ))); - } - num_removed += 1; - } else if path.is_dir() { - force_remove_dir_all(path).map_err(|err| { - UpError::Exec(format!("failed to remove{}: {}", path.display(), err)) - })?; - num_removed += 1; - } else { - return Err(UpError::Exec(format!( - "unexpected path type: {}", - path.display() - ))); - } + if root_removed { + return Ok(Some(format!( + "removed workdir data path {}", + wd_data_path.display().to_string().light_yellow() + ))); } if num_removed == 0 { diff --git a/src/internal/config/up/github_release.rs b/src/internal/config/up/github_release.rs new file mode 100644 index 00000000..33b6675c --- /dev/null +++ b/src/internal/config/up/github_release.rs @@ -0,0 +1,668 @@ +use std::fs::OpenOptions; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +use once_cell::sync::OnceCell; +use serde::Deserialize; +use serde::Serialize; + +use crate::internal::cache::utils as cache_utils; +use crate::internal::cache::CacheObject; +use crate::internal::cache::GithubReleaseOperationCache; +use crate::internal::cache::GithubReleaseVersion; +use crate::internal::cache::GithubReleases; +use crate::internal::cache::UpEnvironmentsCache; +use crate::internal::config::global_config; +use crate::internal::config::up::utils::cleanup_path; +use crate::internal::config::up::utils::force_remove_dir_all; +use crate::internal::config::up::utils::ProgressHandler; +use crate::internal::config::up::utils::UpProgressHandler; +use crate::internal::config::up::UpError; +use crate::internal::config::up::UpOptions; +use crate::internal::config::ConfigValue; +use crate::internal::env::data_home; +use crate::internal::user_interface::StringColor; +use crate::internal::workdir; + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct UpConfigGithubRelease { + /// The repository to install the tool from, should + /// be in the format `owner/repo` + pub repository: String, + + /// The version of the tool to install + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + + /// Whether to install the pre-release version of the tool + /// if it is the most recent matching version + #[serde(default, skip_serializing_if = "cache_utils::is_false")] + pub prerelease: bool, + + /// The URL of the GitHub API; this is only required if downloading + /// using Github Enterprise. By default, this is set to the public + /// GitHub API URL (https://api.github.com). If you are using + /// Github Enterprise, you should set this to the URL of your + /// Github Enterprise instance (e.g. https://github.example.com/api/v3) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + + #[serde(default, skip)] + pub actual_version: OnceCell, +} + +impl UpConfigGithubRelease { + pub fn from_config_value(config_value: Option<&ConfigValue>) -> Self { + let config_value = match config_value { + Some(config_value) => config_value, + None => return UpConfigGithubRelease::default(), + }; + + if let Some(table) = config_value.as_table() { + let repository = ["repository", "repo"] + .iter() + .find_map(|key| table.get(*key)); + + let repository = if let Some(repository) = repository { + if let Some(repository_details) = repository.as_table() { + let owner = repository_details + .get("owner") + .map(|v| v.as_str_forced()) + .unwrap_or(None) + .unwrap_or("".to_string()); + let name = repository_details + .get("name") + .map(|v| v.as_str_forced()) + .unwrap_or(None) + .unwrap_or("".to_string()); + format!("{}/{}", owner, name) + } else if let Some(repository) = repository.as_str_forced() { + repository.to_string() + } else { + "".to_string() + } + } else { + "".to_string() + }; + + let version = table + .get("version") + .map(|v| v.as_str_forced()) + .unwrap_or(None); + let prerelease = table + .get("prerelease") + .map(|v| v.as_bool()) + .unwrap_or(None) + .unwrap_or(false); + let api_url = table + .get("api_url") + .map(|v| v.as_str_forced()) + .unwrap_or(None); + + UpConfigGithubRelease { + repository, + version, + prerelease, + api_url, + ..UpConfigGithubRelease::default() + } + } else if let Some(repository) = config_value.as_str_forced() { + UpConfigGithubRelease { + repository, + ..UpConfigGithubRelease::default() + } + } else { + UpConfigGithubRelease::default() + } + } + + fn bin_path() -> PathBuf { + PathBuf::from(data_home()).join("ghreleases") + } + + fn update_cache(&self, progress_handler: &dyn ProgressHandler) { + let wd = workdir("."); + let wd_id = match wd.id() { + Some(wd_id) => wd_id, + None => return, + }; + + let version = match self.actual_version.get() { + Some(version) => version, + None => { + progress_handler.error_with_message("version not set".to_string()); + return; + } + }; + + progress_handler.progress("updating cache".to_string()); + + if let Err(err) = GithubReleaseOperationCache::exclusive(|ghrelease| { + ghrelease.add_installed(&wd_id, &self.repository, version) + }) { + progress_handler.progress(format!("failed to update github release cache: {}", err)); + return; + } + + let release_version_path = match self.release_version_path() { + Ok(release_version_path) => release_version_path, + Err(err) => { + progress_handler + .error_with_message(format!("failed to get release version path: {}", err)); + return; + } + }; + + if let Err(err) = + UpEnvironmentsCache::exclusive(|up_env| up_env.add_path(&wd_id, release_version_path)) + { + progress_handler.progress(format!("failed to update up environment cache: {}", err)); + return; + } + + progress_handler.progress("updated cache".to_string()); + } + + fn desc(&self) -> String { + if self.repository.is_empty() { + "github release:".to_string() + } else { + format!( + "{} ({}):", + self.repository, + match self.version { + None => "latest".to_string(), + Some(ref version) if version.is_empty() => "latest".to_string(), + Some(ref version) => version.clone(), + } + ) + } + } + + pub fn up( + &self, + options: &UpOptions, + progress_handler: &UpProgressHandler, + ) -> Result<(), UpError> { + progress_handler.init(self.desc().light_blue()); + progress_handler.progress("install github release".to_string()); + + if self.repository.is_empty() { + progress_handler.error_with_message("repository is required".to_string()); + return Err(UpError::Config("repository is required".to_string())); + } + + let releases = self.list_releases(options, progress_handler)?; + let release = self.resolve_release(&releases, progress_handler)?; + + progress_handler.progress(format!("found release {}", release.tag_name.light_yellow())); + + let installed = self.download_release(options, &release, progress_handler)?; + + self.update_cache(progress_handler); + + let version = match self.actual_version.get() { + Some(version) => version.to_string(), + None => "unknown".to_string(), + }; + let msg = match installed { + true => format!("{} installed", version.light_yellow()), + false => format!("{} already installed", version).light_black(), + }; + progress_handler.success_with_message(msg); + + Ok(()) + } + + pub fn down(&self, progress_handler: &UpProgressHandler) -> Result<(), UpError> { + let wd = workdir("."); + let wd_id = match wd.id() { + Some(wd_id) => wd_id, + None => return Ok(()), + }; + + if let Err(err) = GithubReleaseOperationCache::exclusive(|ghrelease| { + progress_handler.init(self.desc().light_blue()); + progress_handler.progress("updating github release dependencies".to_string()); + + let mut updated = false; + + for install in ghrelease + .installed + .iter_mut() + .filter(|install| install.required_by.contains(&wd_id)) + { + install.required_by.retain(|id| id != &wd_id); + updated = true; + } + + updated + }) { + progress_handler.progress(format!("failed to update cache: {}", err).light_yellow()); + } + + progress_handler.success_with_message("github release dependencies cleaned".light_green()); + + Ok(()) + } + + fn list_releases( + &self, + options: &UpOptions, + progress_handler: &UpProgressHandler, + ) -> Result { + let cached_releases = if options.read_cache { + let cache = GithubReleaseOperationCache::get(); + if let Some(releases) = cache.get_releases(&self.repository) { + let releases = releases.clone(); + let config = global_config(); + let expire = config.cache.github_release_versions_expire; + if !releases.is_stale(expire) { + progress_handler.progress("using cached release list".light_black()); + return Ok(releases); + } + Some(releases) + } else { + None + } + } else { + None + }; + + progress_handler.progress("refreshing releases list from GitHub".to_string()); + match self.list_releases_from_api(progress_handler) { + Ok(releases) => { + if options.write_cache { + progress_handler.progress("updating cache with release list".to_string()); + if let Err(err) = GithubReleaseOperationCache::exclusive(|ghrelease| { + ghrelease.add_releases(&self.repository, &releases) + }) { + progress_handler.progress(format!("failed to update cache: {}", err)); + } + } + + Ok(releases) + } + Err(err) => { + if let Some(cached_releases) = cached_releases { + progress_handler.progress(format!( + "{}; {}", + format!("error refreshing release list: {}", err).red(), + "using cached data".light_black() + )); + Ok(cached_releases) + } else { + Err(err) + } + } + } + } + + fn list_releases_from_api( + &self, + progress_handler: &UpProgressHandler, + ) -> Result { + // Use https://api.github.com/repos///releases to + // list the available releases + let api_url = self + .api_url + .clone() + .unwrap_or("https://api.github.com".to_string()); + let releases_url = format!("{}/repos/{}/releases", api_url, self.repository); + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/vnd.github.v3+json"), + ); + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + headers.insert( + "X-GitHub-Api-Version", + reqwest::header::HeaderValue::from_static("2022-11-28"), + ); + + let client = match reqwest::blocking::Client::builder() + .user_agent(format!("omni {}", env!("CARGO_PKG_VERSION"))) + .default_headers(headers) + .build() + { + Ok(client) => client, + Err(err) => { + let errmsg = format!("failed to create client: {}", err); + progress_handler.error_with_message(errmsg.clone()); + return Err(UpError::Exec(errmsg)); + } + }; + + let response = client.get(releases_url).send().map_err(|err| { + let errmsg = format!("failed to get releases: {}", err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + let contents = response.text().map_err(|err| { + let errmsg = format!("failed to read response: {}", err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + let releases = GithubReleases::from_json(&contents).map_err(|err| { + let errmsg = format!("failed to parse releases: {}", err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + Ok(releases) + } + + fn resolve_release( + &self, + releases: &GithubReleases, + progress_handler: &UpProgressHandler, + ) -> Result { + let version = self.version.clone().unwrap_or_else(|| "latest".to_string()); + + let (version, release) = releases.get(&version, self.prerelease).ok_or_else(|| { + let errmsg = "no matching release found".to_string(); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + self.actual_version.set(version.to_string()).map_err(|_| { + let errmsg = "failed to set actual version".to_string(); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + Ok(release) + } + + fn release_version_path(&self) -> Result { + let version = self + .actual_version + .get() + .ok_or_else(|| UpError::Exec("version not set".to_string()))?; + + Ok(Self::bin_path().join(&self.repository).join(version)) + } + + fn download_release( + &self, + options: &UpOptions, + release: &GithubReleaseVersion, + progress_handler: &dyn ProgressHandler, + ) -> Result { + let install_path = self.release_version_path()?; + let version = match self.actual_version.get() { + Some(version) => version.to_string(), + None => "unknown".to_string(), + }; + + if options.read_cache && install_path.exists() && install_path.is_dir() { + progress_handler.progress( + format!("downloaded {} {} (cached)", self.repository, version).light_black(), + ); + + return Ok(false); + } + + // Make a temporary directory to download the release + let tmp_dir = tempfile::Builder::new() + .prefix("omni_download.") + .tempdir() + .map_err(|err| { + progress_handler.error_with_message(format!("failed to create temp dir: {}", err)); + UpError::Exec(format!("failed to create temp dir: {}", err)) + })?; + + // Go over each of the assets that matched the current platform + // and download them all + for asset in &release.assets { + let asset_name = asset.name.clone(); + let asset_url = asset.browser_download_url.clone(); + let asset_path = tmp_dir.path().join(&asset_name); + + progress_handler.progress(format!("downloading {}", asset_name.light_yellow())); + + // Download the asset + let mut response = reqwest::blocking::get(&asset_url).map_err(|err| { + let errmsg = format!("failed to download {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + // Write the file to disk + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(asset_path.clone()) + .map_err(|err| { + let errmsg = format!("failed to open {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + io::copy(&mut response, &mut file).map_err(|err| { + let errmsg = format!("failed to write {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + progress_handler.progress(format!("extracting {}", asset_name.light_yellow())); + + // Open the downloaded file + let archive_file = std::fs::File::open(&asset_path).map_err(|err| { + let errmsg = format!("failed to open {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + + // Strip .zip or .tar.gz from the asset name to get the target dir + let target_dir = asset_name + .strip_suffix(".zip") + .map_or_else(|| asset_name.strip_suffix(".tar.gz"), Some) + .ok_or_else(|| { + let errmsg = format!("file extension not supported: {}", asset_name); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + let target_dir = tmp_dir.path().join(target_dir); + + // Perform the extraction + let is_tgz = asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz"); + let is_zip = asset.name.ends_with(".zip"); + if is_zip { + zip_extract::extract(&archive_file, &target_dir, true).map_err(|err| { + let errmsg = format!("failed to extract {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + } else if is_tgz { + let tar = flate2::read::GzDecoder::new(archive_file); + let mut archive = tar::Archive::new(tar); + archive.unpack(&target_dir).map_err(|err| { + let errmsg = format!("failed to extract {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + } else { + let errmsg = format!("file extension not supported: {}", asset_name); + progress_handler.error_with_message(errmsg.clone()); + return Err(UpError::Exec(errmsg)); + } + + // Locate the binary file(s) in the extracted directory, recursively + // and move them to the workdir data path + for entry in walkdir::WalkDir::new(&target_dir) + .into_iter() + .filter_map(|entry| { + let entry = entry.ok()?; + let entry_path = entry.path(); + if entry_path.is_file() { + let metadata = entry.metadata().ok()?; + let is_executable = metadata.permissions().mode() & 0o111 != 0; + if is_executable { + Some(entry) + } else { + None + } + } else { + None + } + }) + { + let source_path = entry.path(); + let binary_name = source_path + .file_name() + .unwrap_or(source_path.as_os_str()) + .to_string_lossy() + .to_string(); + + progress_handler.progress(format!("found binary {}", binary_name.light_yellow())); + + let target_path = install_path.join(&binary_name); + + // Make sure the target directory exists + if !install_path.exists() { + std::fs::create_dir_all(&install_path).map_err(|err| { + let errmsg = + format!("failed to create {}: {}", install_path.display(), err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + } + + // Copy the binary to the install path + std::fs::copy(source_path, target_path).map_err(|err| { + let errmsg = format!("failed to copy {}: {}", binary_name, err); + progress_handler.error_with_message(errmsg.clone()); + + // Force delete the install path if we fail to copy + // the binary to avoid leaving a partial installation + // behind + let _ = force_remove_dir_all(&install_path); + + UpError::Exec(errmsg) + })?; + } + } + + progress_handler.progress(format!("downloaded {} {}", self.repository, version)); + + Ok(true) + } + + // TODO: implement cleanup + pub fn cleanup(progress_handler: &UpProgressHandler) -> Result, UpError> { + let wd = workdir("."); + let wd_id = match wd.id() { + Some(wd_id) => wd_id, + None => return Err(UpError::Exec("failed to get workdir id".to_string())), + }; + + let bin_path = Self::bin_path(); + let mut return_value: Result<(bool, usize, Vec), UpError> = + Err(UpError::Exec("cleanup_path not run".to_string())); + + if let Err(err) = GithubReleaseOperationCache::exclusive(|ghrelease| { + progress_handler.init("github releases:".light_blue()); + progress_handler.progress("checking for unused github releases".to_string()); + + let mut updated = false; + + let expected_paths = ghrelease + .installed + .iter_mut() + .filter_map(|install| { + // Cleanup the references to this repository for + // any installed github release that is not currently + // listed in the up configuration + if install.required_by.contains(&wd_id) && install.stale() { + install.required_by.retain(|id| id != &wd_id); + updated = true; + } + + // Only return the path if the github release is + // expected, as we will clear the bin path from + // all unexpected github releases + if install.required_by.is_empty() { + None + } else { + Some(bin_path.join(&install.repository).join(&install.version)) + } + }) + .collect::>(); + + return_value = cleanup_path(&bin_path, expected_paths, progress_handler, true); + + return_value.is_ok() && updated + }) { + progress_handler.progress(format!("failed to update cache: {}", err).light_yellow()); + } + + let (root_removed, num_removed, removed_paths) = return_value?; + + if root_removed { + return Ok(Some(format!( + "removed github release bin path {}", + bin_path.display().to_string().light_yellow() + ))); + } + + if num_removed == 0 { + return Ok(None); + } + + // We want to go over the paths that were removed to + // return a proper message about the github releases + // that were removed + let removed_releases = removed_paths + .iter() + .filter_map(|path| { + // Path should starts with the bin path if it is a release + let rest_of_path = match path.strip_prefix(&bin_path) { + Ok(rest_of_path) => rest_of_path, + Err(_) => return None, + }; + + // Path should have three components left after stripping + // the bin path: the repository (2) and the version (1) + let parts = rest_of_path.components().collect::>(); + if parts.len() != 3 { + return None; + } + + let parts = parts + .into_iter() + .map(|part| part.as_os_str().to_string_lossy().to_string()) + .collect::>(); + + let repo_owner = parts[0].clone(); + let repo_name = parts[1].clone(); + let version = parts[2].clone(); + + Some((repo_owner, repo_name, version)) + }) + .collect::>(); + + let removed_releases = removed_releases + .iter() + .map(|(repo_owner, repo_name, version)| { + format!( + "{}/{} {}", + repo_owner.light_yellow(), + repo_name.light_yellow(), + version.light_yellow() + ) + .light_yellow() + }) + .collect::>(); + + Ok(Some(format!("removed {}", removed_releases.join(", ")))) + } +} diff --git a/src/internal/config/up/golang.rs b/src/internal/config/up/golang.rs index 58a8b049..c34bde57 100644 --- a/src/internal/config/up/golang.rs +++ b/src/internal/config/up/golang.rs @@ -14,9 +14,9 @@ use crate::internal::cache::utils::CacheObject; use crate::internal::cache::UpEnvironmentsCache; use crate::internal::commands::utils::abs_path; use crate::internal::config::up::utils::data_path_dir_hash; +use crate::internal::config::up::utils::ProgressHandler; use crate::internal::config::up::utils::UpProgressHandler; use crate::internal::config::up::AsdfToolUpVersion; -use crate::internal::config::up::ProgressHandler; use crate::internal::config::up::UpConfigAsdfBase; use crate::internal::config::up::UpError; use crate::internal::config::up::UpOptions; diff --git a/src/internal/config/up/mod.rs b/src/internal/config/up/mod.rs index 7ee66e56..b486ee6d 100644 --- a/src/internal/config/up/mod.rs +++ b/src/internal/config/up/mod.rs @@ -13,6 +13,9 @@ pub(crate) use bundler::UpConfigBundler; pub(crate) mod custom; pub(crate) use custom::UpConfigCustom; +pub(crate) mod github_release; +pub(crate) use github_release::UpConfigGithubRelease; + pub(crate) mod golang; pub(crate) use golang::UpConfigGolang; @@ -37,9 +40,3 @@ pub(crate) mod error; pub(crate) use error::UpError; pub(crate) mod utils; -pub(crate) use utils::run_progress; -pub(crate) use utils::ProgressHandler; -pub(crate) use utils::SpinnerProgressHandler; - -pub(crate) mod askpass; -pub(crate) use askpass::AskPassListener; diff --git a/src/internal/config/up/python.rs b/src/internal/config/up/python.rs index de0431d7..f098c11b 100644 --- a/src/internal/config/up/python.rs +++ b/src/internal/config/up/python.rs @@ -11,12 +11,12 @@ use crate::internal::cache::utils::CacheObject; use crate::internal::cache::UpEnvironmentsCache; use crate::internal::commands::utils::abs_path; use crate::internal::config::up::asdf_tool_path; -use crate::internal::config::up::run_progress; use crate::internal::config::up::utils::data_path_dir_hash; +use crate::internal::config::up::utils::run_progress; +use crate::internal::config::up::utils::ProgressHandler; use crate::internal::config::up::utils::RunConfig; use crate::internal::config::up::utils::UpProgressHandler; use crate::internal::config::up::AsdfToolUpVersion; -use crate::internal::config::up::ProgressHandler; use crate::internal::config::up::UpConfigAsdfBase; use crate::internal::config::up::UpError; use crate::internal::config::up::UpOptions; diff --git a/src/internal/config/up/tool.rs b/src/internal/config/up/tool.rs index f9ffc816..49fb027e 100644 --- a/src/internal/config/up/tool.rs +++ b/src/internal/config/up/tool.rs @@ -12,6 +12,7 @@ use crate::internal::config::up::UpConfig; use crate::internal::config::up::UpConfigAsdfBase; use crate::internal::config::up::UpConfigBundler; use crate::internal::config::up::UpConfigCustom; +use crate::internal::config::up::UpConfigGithubRelease; use crate::internal::config::up::UpConfigGolang; use crate::internal::config::up::UpConfigHomebrew; use crate::internal::config::up::UpConfigNix; @@ -49,6 +50,10 @@ pub enum UpConfigTool { Custom(UpConfigCustom), // TODO: Dnf(UpConfigDnf), + /// GithubRelease represents a tool that can be installed from + /// a github release. + GithubRelease(UpConfigGithubRelease), + /// Go represents the golang tool. Go(UpConfigGolang), @@ -105,6 +110,9 @@ impl Serialize for UpConfigTool { create_hashmap("bundler", config).serialize(serializer) } UpConfigTool::Custom(config) => create_hashmap("custom", config).serialize(serializer), + UpConfigTool::GithubRelease(config) => { + create_hashmap("github-release", config).serialize(serializer) + } UpConfigTool::Go(config) => create_hashmap("go", config).serialize(serializer), UpConfigTool::Homebrew(config) => { create_hashmap("homebrew", config).serialize(serializer) @@ -155,6 +163,9 @@ impl UpConfigTool { "custom" => Some(UpConfigTool::Custom(UpConfigCustom::from_config_value( config_value, ))), + "github-release" | "github_release" | "githubrelease" | "ghrelease" => Some( + UpConfigTool::GithubRelease(UpConfigGithubRelease::from_config_value(config_value)), + ), "go" | "golang" => Some(UpConfigTool::Go(UpConfigGolang::from_config_value( config_value, ))), @@ -221,6 +232,7 @@ impl UpConfigTool { UpConfigTool::Bash(config) => config.up(options, progress_handler), UpConfigTool::Bundler(config) => config.up(progress_handler), UpConfigTool::Custom(config) => config.up(progress_handler), + UpConfigTool::GithubRelease(config) => config.up(options, progress_handler), UpConfigTool::Go(config) => config.up(options, progress_handler), UpConfigTool::Homebrew(config) => config.up(options, progress_handler), UpConfigTool::Nix(config) => config.up(options, progress_handler), @@ -257,6 +269,7 @@ impl UpConfigTool { UpConfigTool::Bash(config) => config.down(progress_handler), UpConfigTool::Bundler(config) => config.down(progress_handler), UpConfigTool::Custom(config) => config.down(progress_handler), + UpConfigTool::GithubRelease(config) => config.down(progress_handler), UpConfigTool::Go(config) => config.down(progress_handler), UpConfigTool::Homebrew(config) => config.down(progress_handler), UpConfigTool::Nix(config) => config.down(progress_handler), @@ -294,6 +307,7 @@ impl UpConfigTool { UpConfigTool::Bash(config) => config.was_upped(), // UpConfigTool::Bundler(config) => config.was_upped(), // UpConfigTool::Custom(config) => config.was_upped(), + // UpConfigTool::GithubRelease(config) => config.was_upped(), UpConfigTool::Go(config) => config.was_upped(), // UpConfigTool::Homebrew(config) => config.was_upped(), UpConfigTool::Nix(config) => config.was_upped(), @@ -312,13 +326,7 @@ impl UpConfigTool { .iter() .flat_map(|config| config.data_paths()) .collect(), - UpConfigTool::Bash(config) => config.data_paths(), - // UpConfigTool::Bundler(config) => config.data_paths(), - UpConfigTool::Go(config) => config.data_paths(), - // UpConfigTool::Homebrew(config) => config.data_paths(), - UpConfigTool::Nix(config) => config.data_paths(), - UpConfigTool::Nodejs(config) => config.asdf_base.data_paths(), - UpConfigTool::Or(configs) => { + UpConfigTool::Any(configs) | UpConfigTool::Or(configs) => { match configs .iter() .find(|config| config.is_available() && config.was_upped()) @@ -327,6 +335,14 @@ impl UpConfigTool { None => vec![], } } + UpConfigTool::Bash(config) => config.data_paths(), + // UpConfigTool::Bundler(config) => config.data_paths(), + // UpConfigTool::Custom(config) => config.data_paths(), + // UpConfigTool::GithubRelease(config) => config.data_paths(), + UpConfigTool::Go(config) => config.data_paths(), + // UpConfigTool::Homebrew(config) => config.data_paths(), + UpConfigTool::Nix(config) => config.data_paths(), + UpConfigTool::Nodejs(config) => config.asdf_base.data_paths(), UpConfigTool::Python(config) => config.asdf_base.data_paths(), UpConfigTool::Ruby(config) => config.data_paths(), UpConfigTool::Rust(config) => config.data_paths(), @@ -343,6 +359,7 @@ impl UpConfigTool { UpConfigTool::Bash(_) => "bash", UpConfigTool::Bundler(_) => "bundler", UpConfigTool::Custom(_) => "custom", + UpConfigTool::GithubRelease(_) => "github-release", UpConfigTool::Go(_) => "go", UpConfigTool::Homebrew(_) => "homebrew", UpConfigTool::Nix(_) => "nix", diff --git a/src/internal/config/up/askpass.rs b/src/internal/config/up/utils/askpass.rs similarity index 99% rename from src/internal/config/up/askpass.rs rename to src/internal/config/up/utils/askpass.rs index 718d569f..9c96c049 100644 --- a/src/internal/config/up/askpass.rs +++ b/src/internal/config/up/utils/askpass.rs @@ -210,7 +210,7 @@ impl AskPassListener { context.insert("PREFER_GUI", &config.askpass.prefer_gui); // Prepare the template - let template = include_str!("../../../../templates/askpass.sh.tmpl"); + let template = include_str!("../../../../../templates/askpass.sh.tmpl"); // Render the script for all the required askpass tools for tool in &needs_askpass { diff --git a/src/internal/config/up/utils/directory.rs b/src/internal/config/up/utils/directory.rs new file mode 100644 index 00000000..5430c348 --- /dev/null +++ b/src/internal/config/up/utils/directory.rs @@ -0,0 +1,223 @@ +use std::collections::HashMap; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; + +use blake3::Hasher; +use itertools::any; +use normalize_path::NormalizePath; + +use crate::internal::config::loader::WORKDIR_CONFIG_FILES; +use crate::internal::config::up::utils::ProgressHandler; +use crate::internal::config::up::UpError; +use crate::internal::utils::base62_encode; +use crate::internal::workdir; + +/// Return the name of the directory to use in the data path +/// for the given subdirectory of the work directory. +pub fn data_path_dir_hash(dir: &str) -> String { + let dir = Path::new(dir).normalize().to_string_lossy().to_string(); + + if dir.is_empty() { + "root".to_string() + } else { + let mut hasher = Hasher::new(); + hasher.update(dir.as_bytes()); + let hash_bytes = hasher.finalize(); + let hash_b62 = base62_encode(hash_bytes.as_bytes())[..20].to_string(); + hash_b62 + } +} + +/// Remove the given directory, even if it contains read-only files. +/// This will first try to remove the directory normally, and if that +/// fails with a PermissionDenied error, it will make all files and +/// directories in the given path writeable, and then try again. +pub fn force_remove_dir_all>(path: P) -> std::io::Result<()> { + let path = path.as_ref(); + if path.exists() { + match std::fs::remove_dir_all(path) { + Ok(_) => {} + Err(err) => { + if err.kind() == std::io::ErrorKind::PermissionDenied { + set_writeable_recursive(path)?; + std::fs::remove_dir_all(path)?; + } else { + return Err(err); + } + } + } + } + Ok(()) +} + +/// Set all files and directories in the given path to be writeable. +/// This is useful when we want to remove a directory that contains +/// read-only files, which would otherwise fail. +pub fn set_writeable_recursive>(path: P) -> std::io::Result<()> { + for entry in walkdir::WalkDir::new(&path) + .into_iter() + .filter_map(|e| e.ok()) + { + let metadata = entry.metadata()?; + let mut permissions = metadata.permissions(); + if permissions.readonly() { + permissions.set_mode(0o775); + std::fs::set_permissions(entry.path(), permissions)?; + } + } + Ok(()) +} + +/// Return the modification time of the configuration files +/// for the work directory at the given path. +pub fn get_config_mod_times>(path: T) -> HashMap { + let mut mod_times = HashMap::new(); + + if let Some(wdroot) = workdir(path.as_ref()).root() { + for config_file in WORKDIR_CONFIG_FILES { + let wd_config_path = PathBuf::from(wdroot).join(config_file); + if let Ok(metadata) = std::fs::metadata(&wd_config_path) { + if let Ok(modified) = metadata.modified() { + if let Ok(modified) = modified.duration_since(std::time::UNIX_EPOCH) { + let modified = modified.as_secs(); + mod_times.insert(config_file.to_string(), modified); + } + } + } + } + } + + mod_times +} + +/// cleanup_path is a function that removes all files and directories +/// in the given path that are not expected. It will return the number +/// of files and directories removed, and a list of the paths that were +/// removed. +pub fn cleanup_path( + path: impl AsRef, + expected_paths: Vec>, + progress_handler: &dyn ProgressHandler, + remove_root: bool, +) -> Result<(bool, usize, Vec), UpError> { + // Convert the root path to a path buffer + let path = PathBuf::from(path.as_ref()); + + // Exit early if the root path does not exist + if !path.exists() { + return Ok((false, 0, vec![])); + } + + // Exit early on error if the root path is not a directory + if !path.is_dir() { + return Err(UpError::Exec(format!( + "expected directory, got: {}", + path.display() + ))); + } + + // Convert the expected paths to path buffers and filter out the + // paths that are not in the root path + let expected_paths = expected_paths + .into_iter() + .map(|p| PathBuf::from(p.as_ref())) + .filter(|p| p.starts_with(&path)) + .collect::>(); + + // If there are no expected data paths, we can remove the workdir + // data path entirely + if expected_paths.is_empty() { + if remove_root { + progress_handler.progress(format!("removing {}", path.display())); + force_remove_dir_all(path.clone()).map_err(|err| { + UpError::Exec(format!("failed to remove {}: {}", path.display(), err)) + })?; + } + return Ok((remove_root, 0, vec![])); + } + + // If there are expected paths, we want to do a breadth-first search + // so that we can remove paths fast when they are not expected; we + // can stop in depth when we find a path that is expected (since it + // means that any deeper path is also expected) + let mut known_unknown_paths = vec![]; + let mut removed_paths = vec![]; + for entry in walkdir::WalkDir::new(path.clone()) + .into_iter() + .filter_entry(|e| { + // If the path is the root, we want to keep it + if e.path() == path { + return true; + } + + // Check if the path is known, in which case we can skip it + // and its children + if any(expected_paths.iter(), |expected_path| { + e.path() == *expected_path + }) { + return false; + } + + // If we're here, the path is not known, but we want to keep + // digging if it is the beginning of a known path; we will need + // to filter those paths out after + if any(expected_paths.iter(), |expected_path| { + expected_path.starts_with(e.path()) + }) { + return true; + } + + // If we're here, the path is not known and is not the beginning + // of a known path, so we want to keep it as it will need to get + // removed; however, we don't want to dig indefinitely, so we will + // keep track of paths that we already marked as unknown, so we + // can skip their children + if any(known_unknown_paths.iter(), |unknown_path| { + e.path().starts_with(unknown_path) + }) { + return false; + } + + // If we're here, the path is not known and is not the beginning + // of a known path, so we want to keep it as it will need to get + known_unknown_paths.push(e.path().to_path_buf()); + true + }) + .filter_map(|e| e.ok()) + // Filter the parents of known paths since we don't want to remove them + .filter(|e| { + !any(expected_paths.iter(), |expected_path| { + expected_path.starts_with(e.path()) + }) + }) + { + let path = entry.path(); + + progress_handler.progress(format!("removing {}", path.display())); + + if path.is_file() { + if let Err(error) = std::fs::remove_file(path) { + return Err(UpError::Exec(format!( + "failed to remove {}: {}", + path.display(), + error + ))); + } + removed_paths.push(path.to_path_buf()); + } else if path.is_dir() { + force_remove_dir_all(path).map_err(|err| { + UpError::Exec(format!("failed to remove{}: {}", path.display(), err)) + })?; + removed_paths.push(path.to_path_buf()); + } else { + return Err(UpError::Exec(format!( + "unexpected path type: {}", + path.display() + ))); + } + } + + let num_removed = removed_paths.len(); + Ok((false, num_removed, removed_paths)) +} diff --git a/src/internal/config/up/utils/mod.rs b/src/internal/config/up/utils/mod.rs new file mode 100644 index 00000000..d8538d21 --- /dev/null +++ b/src/internal/config/up/utils/mod.rs @@ -0,0 +1,31 @@ +pub(crate) mod askpass; +pub(crate) use askpass::AskPassListener; +pub(crate) use askpass::AskPassRequest; + +pub(crate) mod directory; +pub(crate) use directory::cleanup_path; +pub(crate) use directory::data_path_dir_hash; +pub(crate) use directory::force_remove_dir_all; +pub(crate) use directory::get_config_mod_times; + +pub(crate) mod print_progress_handler; +pub(crate) use print_progress_handler::PrintProgressHandler; + +pub(crate) mod progress_handler; +pub(crate) use progress_handler::get_command_output; +pub(crate) use progress_handler::run_command_with_handler; +pub(crate) use progress_handler::run_progress; +pub(crate) use progress_handler::ProgressHandler; + +pub(crate) mod run_config; +pub(crate) use run_config::RunConfig; + +pub(crate) mod spinner_progress_handler; +pub(crate) use spinner_progress_handler::SpinnerProgressHandler; + +pub(crate) mod up_progress_handler; +pub(crate) use up_progress_handler::UpProgressHandler; + +pub(crate) mod void_progress_handler; +#[allow(unused_imports)] +pub(crate) use void_progress_handler::VoidProgressHandler; diff --git a/src/internal/config/up/utils/print_progress_handler.rs b/src/internal/config/up/utils/print_progress_handler.rs new file mode 100644 index 00000000..d99abe16 --- /dev/null +++ b/src/internal/config/up/utils/print_progress_handler.rs @@ -0,0 +1,78 @@ +use crate::internal::config::up::utils::ProgressHandler; +use crate::internal::user_interface::StringColor; + +#[derive(Debug, Clone)] +pub struct PrintProgressHandler { + template: String, +} + +impl PrintProgressHandler { + pub fn new(desc: String, progress: Option<(usize, usize)>) -> Self { + let prefix = if let Some((current, total)) = progress { + let padding = format!("{}", total).len(); + format!( + "[{:padding$}/{:padding$}] ", + current, + total, + padding = padding + ) + .bold() + .light_black() + } else { + "".to_string() + }; + + let template = format!("{}{{}} {} {{}}", prefix, desc); + + PrintProgressHandler { template } + } +} + +impl ProgressHandler for PrintProgressHandler { + fn println(&self, message: String) { + eprintln!("{}", message); + } + + fn progress(&self, message: String) { + eprintln!( + "{}", + self.template + .replacen("{}", "-".light_black().as_str(), 1) + .replacen("{}", message.as_str(), 1) + ); + } + + fn success(&self) { + self.success_with_message("done".to_string()); + } + + fn success_with_message(&self, message: String) { + eprintln!( + "{}", + self.template + .replacen("{}", "✔".green().as_str(), 1) + .replacen("{}", message.as_str(), 1) + ); + } + + fn error(&self) { + self.error_with_message("error".to_string()); + } + + fn error_with_message(&self, message: String) { + eprintln!( + "{}", + self.template + .replacen("{}", "✖".red().as_str(), 1) + .replacen("{}", message.red().as_str(), 1) + ); + } + + fn hide(&self) { + // do nothing + } + + fn show(&self) { + // do nothing + } +} diff --git a/src/internal/config/up/utils.rs b/src/internal/config/up/utils/progress_handler.rs similarity index 50% rename from src/internal/config/up/utils.rs rename to src/internal/config/up/utils/progress_handler.rs index 4877a38c..98ba4d54 100644 --- a/src/internal/config/up/utils.rs +++ b/src/internal/config/up/utils/progress_handler.rs @@ -1,17 +1,5 @@ -use std::cell::Cell; -use std::collections::HashMap; use std::io::Write; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::path::PathBuf; -use blake3::Hasher; -use indicatif::MultiProgress; -use indicatif::ProgressBar; -use indicatif::ProgressDrawTarget; -use indicatif::ProgressStyle; -use normalize_path::NormalizePath; -use once_cell::sync::OnceCell; use regex::Regex; use tempfile::NamedTempFile; use time::format_description::well_known::Rfc3339; @@ -22,193 +10,21 @@ use tokio::process::Command as TokioCommand; use tokio::runtime::Runtime; use tokio::time::Duration; -use crate::internal::config::loader::WORKDIR_CONFIG_FILES; -use crate::internal::config::up::AskPassListener; +use crate::internal::config::up::utils::AskPassListener; +use crate::internal::config::up::utils::RunConfig; use crate::internal::config::up::UpError; -use crate::internal::env::shell_is_interactive; -use crate::internal::user_interface::ensure_newline_from_len; -use crate::internal::user_interface::print::strip_ansi_codes; use crate::internal::user_interface::StringColor; -use crate::internal::utils::base62_encode; -use crate::internal::workdir; use crate::omni_warning; -#[derive(Debug, Clone)] -pub struct RunConfig { - timeout: Option, - strip_ctrl_chars: bool, - askpass: bool, -} - -impl Default for RunConfig { - fn default() -> Self { - RunConfig { - timeout: None, - strip_ctrl_chars: true, - askpass: false, - } - } -} - -impl RunConfig { - pub fn new() -> Self { - Self::default().without_ctrl_chars() - } - - pub fn with_timeout(&mut self, timeout: u64) -> Self { - self.timeout = Some(Duration::from_secs(timeout)); - self.clone() - } - - // pub fn with_ctrl_chars(&mut self) -> Self { - // self.strip_ctrl_chars = false; - // self.clone() - // } - - pub fn without_ctrl_chars(&mut self) -> Self { - self.strip_ctrl_chars = true; - self.clone() - } - - pub fn with_askpass(&mut self) -> Self { - self.askpass = true; - self.clone() - } - - pub fn askpass(&self) -> bool { - self.askpass - } - - pub fn timeout(&self) -> Option { - self.timeout - } -} - -pub struct UpProgressHandler<'a> { - handler: OnceCell>, - step: Option<(usize, usize)>, - prefix: String, - parent: Option<&'a UpProgressHandler<'a>>, - allow_ending: bool, -} - -impl<'a> UpProgressHandler<'a> { - pub fn new(progress: Option<(usize, usize)>) -> Self { - UpProgressHandler { - handler: OnceCell::new(), - step: progress, - prefix: "".to_string(), - parent: None, - allow_ending: true, - } - } - - pub fn init(&self, desc: String) -> bool { - if self.handler.get().is_some() || self.parent.is_some() { - return false; - } - let boxed_handler: Box = if shell_is_interactive() { - Box::new(SpinnerProgressHandler::new(desc, self.step)) - } else { - Box::new(PrintProgressHandler::new(desc, self.step)) - }; - if self.handler.set(boxed_handler).is_err() { - panic!("failed to set progress handler"); - } - true - } - - fn handler(&self) -> &dyn ProgressHandler { - if let Some(parent) = self.parent { - return parent.handler(); - } - - self.handler - .get_or_init(|| { - let desc = "".to_string(); - let boxed_handler: Box = if shell_is_interactive() { - Box::new(SpinnerProgressHandler::new(desc, self.step)) - } else { - Box::new(PrintProgressHandler::new(desc, self.step)) - }; - boxed_handler - }) - .as_ref() - } - - pub fn subhandler(&'a self, prefix: &dyn ToString) -> UpProgressHandler<'a> { - UpProgressHandler { - handler: OnceCell::new(), - step: None, - prefix: prefix.to_string(), - parent: Some(self), - allow_ending: false, - } - } - - pub fn step(&self) -> Option<(usize, usize)> { - if let Some(parent) = self.parent { - parent.step() - } else { - self.step - } - } - - fn format_message(&self, message: String) -> String { - let message = format!("{}{}", self.prefix, message); - match self.parent { - Some(parent) => parent.format_message(message), - None => message, - } - } -} - -impl ProgressHandler for UpProgressHandler<'_> { - fn progress(&self, message: String) { - let message = self.format_message(message); - self.handler().progress(message); - } - - fn success(&self) { - self.handler().success(); - } - - fn success_with_message(&self, message: String) { - let message = self.format_message(message); - if self.allow_ending { - self.handler().success_with_message(message); - } else { - self.handler().progress(message); - } - } - - fn error(&self) { - if self.allow_ending { - self.handler().error(); - } - } - - fn error_with_message(&self, message: String) { - let message = self.format_message(message); - if self.allow_ending { - self.handler().error_with_message(message); - } else { - self.handler().progress(message); - } - } - - fn hide(&self) { - self.handler().hide(); - } - - fn show(&self) { - self.handler().show(); - } - - fn println(&self, message: String) { - let message = self.format_message(message); - self.handler().println(message); - } +pub trait ProgressHandler { + fn println(&self, message: String); + fn progress(&self, message: String); + fn success(&self); + fn success_with_message(&self, message: String); + fn error(&self); + fn error_with_message(&self, message: String); + fn hide(&self); + fn show(&self); } pub fn run_progress( @@ -379,7 +195,7 @@ async fn async_get_output( } } -pub async fn async_timeout(run_config: &RunConfig) -> Option<()> { +async fn async_timeout(run_config: &RunConfig) -> Option<()> { if let Some(timeout) = run_config.timeout() { tokio::time::sleep(timeout).await; Some(()) @@ -597,361 +413,4 @@ where fn filter_control_characters(input: &str) -> String { let control_chars_regex = Regex::new(r"(\x1B\[[0-9;]*[ABCDK]|\x0D)").unwrap(); control_chars_regex.replace_all(input, "").to_string() - // let regex = Regex::new(r"[[:cntrl:]]").unwrap(); - // let cleaned_text = regex.replace_all(input, |caps: ®ex::Captures<'_>| { - // let control_char = caps.get(0).unwrap().as_str(); - // format!("\\x{:02X}", control_char.chars().next().unwrap() as u8) - // }); - // cleaned_text.to_string() -} - -pub trait ProgressHandler { - fn println(&self, message: String); - fn progress(&self, message: String); - fn success(&self); - fn success_with_message(&self, message: String); - fn error(&self); - fn error_with_message(&self, message: String); - fn hide(&self); - fn show(&self); -} - -#[derive(Debug, Clone)] -pub struct SpinnerProgressHandler { - spinner: ProgressBar, - template: String, - ensure_newline: bool, - base_len: usize, - last_message_len: Cell, -} - -impl SpinnerProgressHandler { - pub fn new(desc: String, progress: Option<(usize, usize)>) -> Self { - Self::new_with_params(desc, progress, None) - } - - pub fn new_with_multi( - desc: String, - progress: Option<(usize, usize)>, - multiprogress: MultiProgress, - ) -> Self { - Self::new_with_params(desc, progress, Some(multiprogress)) - } - - pub fn new_with_params( - desc: String, - progress: Option<(usize, usize)>, - multiprogress: Option, - ) -> Self { - let template = format!("{{prefix}}{} {} {{msg}}", "{spinner}".yellow(), desc,); - - // Base len = length of the description + 3 (1 for the spinner and 2 for the spaces) - let mut base_len = desc.len() + 4; - - let mut ensure_newline = true; - let spinner = if let Some(multiprogress) = multiprogress { - ensure_newline = false; - multiprogress.add(ProgressBar::new_spinner()) - } else { - ProgressBar::new_spinner() - }; - spinner.set_style( - ProgressStyle::default_spinner() - .template(template.as_str()) - .unwrap(), - ); - spinner.set_message("-"); - spinner.enable_steady_tick(Duration::from_millis(50)); - - if let Some((current, total)) = progress { - let padding = format!("{}", total).len(); - spinner.set_prefix( - format!( - "[{:padding$}/{:padding$}] ", - current, - total, - padding = padding - ) - .bold() - .light_black(), - ); - // Increase the base length to account for the prefix, which is the padding - // length times two (current and total) + the 2 brackets + the slash + the space - base_len += padding * 2 + 4; - } - - SpinnerProgressHandler { - spinner, - template, - ensure_newline, - base_len, - last_message_len: Cell::new(0), - } - } - - fn replace_spinner(&self, replace_by: String) { - let template = self.template.replace("{spinner}", replace_by.as_str()); - self.spinner.set_style( - ProgressStyle::default_spinner() - .template(template.as_str()) - .unwrap(), - ); - } - - fn update_last_message(&self, message: &str) { - let message = strip_ansi_codes(message); - let len = message.len(); - self.last_message_len.set(len); - } - - fn cur_len(&self) -> usize { - self.base_len + self.last_message_len.get() - } - - fn ensure_newline(&self) { - if self.ensure_newline { - ensure_newline_from_len(self.cur_len()); - } - } -} - -impl ProgressHandler for SpinnerProgressHandler { - fn println(&self, message: String) { - self.spinner.println(message); - } - - fn progress(&self, message: String) { - self.update_last_message(&message); - self.spinner.set_message(message); - } - - fn success(&self) { - let message = "done".to_string(); - self.update_last_message(&message); - // self.replace_spinner("✔".green()); - // self.spinner.finish(); - self.success_with_message(message); - self.ensure_newline(); - } - - fn success_with_message(&self, message: String) { - self.update_last_message(&message); - self.replace_spinner("✔".green()); - self.spinner.finish_with_message(message); - self.ensure_newline(); - } - - fn error(&self) { - let message = self.spinner.message().to_string(); - self.update_last_message(&message); - self.replace_spinner("✖".red()); - self.spinner.finish_with_message(message.red()); - self.ensure_newline(); - } - - fn error_with_message(&self, message: String) { - self.update_last_message(&message); - self.replace_spinner("✖".red()); - self.spinner.finish_with_message(message.red()); - self.ensure_newline(); - } - - fn hide(&self) { - self.spinner.set_draw_target(ProgressDrawTarget::hidden()); - } - - fn show(&self) { - self.spinner.set_draw_target(ProgressDrawTarget::stderr()); - } -} - -#[derive(Debug, Clone)] -pub struct VoidProgressHandler {} - -impl ProgressHandler for VoidProgressHandler { - fn println(&self, _message: String) { - // do nothing - } - - fn progress(&self, _message: String) { - // do nothing - } - - fn success(&self) { - // do nothing - } - - fn success_with_message(&self, _message: String) { - // do nothing - } - - fn error(&self) { - // do nothing - } - - fn error_with_message(&self, _message: String) { - // do nothing - } - - fn hide(&self) { - // do nothing - } - - fn show(&self) { - // do nothing - } -} - -#[derive(Debug, Clone)] -pub struct PrintProgressHandler { - template: String, -} - -impl PrintProgressHandler { - pub fn new(desc: String, progress: Option<(usize, usize)>) -> Self { - let prefix = if let Some((current, total)) = progress { - let padding = format!("{}", total).len(); - format!( - "[{:padding$}/{:padding$}] ", - current, - total, - padding = padding - ) - .bold() - .light_black() - } else { - "".to_string() - }; - - let template = format!("{}{{}} {} {{}}", prefix, desc); - - PrintProgressHandler { template } - } -} - -impl ProgressHandler for PrintProgressHandler { - fn println(&self, message: String) { - eprintln!("{}", message); - } - - fn progress(&self, message: String) { - eprintln!( - "{}", - self.template - .replacen("{}", "-".light_black().as_str(), 1) - .replacen("{}", message.as_str(), 1) - ); - } - - fn success(&self) { - self.success_with_message("done".to_string()); - } - - fn success_with_message(&self, message: String) { - eprintln!( - "{}", - self.template - .replacen("{}", "✔".green().as_str(), 1) - .replacen("{}", message.as_str(), 1) - ); - } - - fn error(&self) { - self.error_with_message("error".to_string()); - } - - fn error_with_message(&self, message: String) { - eprintln!( - "{}", - self.template - .replacen("{}", "✖".red().as_str(), 1) - .replacen("{}", message.red().as_str(), 1) - ); - } - - fn hide(&self) { - // do nothing - } - - fn show(&self) { - // do nothing - } -} - -/// Return the name of the directory to use in the data path -/// for the given subdirectory of the work directory. -pub fn data_path_dir_hash(dir: &str) -> String { - let dir = Path::new(dir).normalize().to_string_lossy().to_string(); - - if dir.is_empty() { - "root".to_string() - } else { - let mut hasher = Hasher::new(); - hasher.update(dir.as_bytes()); - let hash_bytes = hasher.finalize(); - let hash_b62 = base62_encode(hash_bytes.as_bytes())[..20].to_string(); - hash_b62 - } -} - -/// Remove the given directory, even if it contains read-only files. -/// This will first try to remove the directory normally, and if that -/// fails with a PermissionDenied error, it will make all files and -/// directories in the given path writeable, and then try again. -pub fn force_remove_dir_all>(path: P) -> std::io::Result<()> { - let path = path.as_ref(); - if path.exists() { - match std::fs::remove_dir_all(path) { - Ok(_) => {} - Err(err) => { - if err.kind() == std::io::ErrorKind::PermissionDenied { - set_writeable_recursive(path)?; - std::fs::remove_dir_all(path)?; - } else { - return Err(err); - } - } - } - } - Ok(()) -} - -/// Set all files and directories in the given path to be writeable. -/// This is useful when we want to remove a directory that contains -/// read-only files, which would otherwise fail. -pub fn set_writeable_recursive>(path: P) -> std::io::Result<()> { - for entry in walkdir::WalkDir::new(&path) - .into_iter() - .filter_map(|e| e.ok()) - { - let metadata = entry.metadata()?; - let mut permissions = metadata.permissions(); - if permissions.readonly() { - permissions.set_mode(0o775); - std::fs::set_permissions(entry.path(), permissions)?; - } - } - Ok(()) -} - -/// Return the modification time of the configuration files -/// for the work directory at the given path. -pub fn get_config_mod_times>(path: T) -> HashMap { - let mut mod_times = HashMap::new(); - - if let Some(wdroot) = workdir(path.as_ref()).root() { - for config_file in WORKDIR_CONFIG_FILES { - let wd_config_path = PathBuf::from(wdroot).join(config_file); - if let Ok(metadata) = std::fs::metadata(&wd_config_path) { - if let Ok(modified) = metadata.modified() { - if let Ok(modified) = modified.duration_since(std::time::UNIX_EPOCH) { - let modified = modified.as_secs(); - mod_times.insert(config_file.to_string(), modified); - } - } - } - } - } - - mod_times } diff --git a/src/internal/config/up/utils/run_config.rs b/src/internal/config/up/utils/run_config.rs new file mode 100644 index 00000000..7cbda26f --- /dev/null +++ b/src/internal/config/up/utils/run_config.rs @@ -0,0 +1,47 @@ +use tokio::time::Duration; + +#[derive(Debug, Clone)] +pub struct RunConfig { + pub timeout: Option, + pub strip_ctrl_chars: bool, + pub askpass: bool, +} + +impl Default for RunConfig { + fn default() -> Self { + RunConfig { + timeout: None, + strip_ctrl_chars: true, + askpass: false, + } + } +} + +impl RunConfig { + pub fn new() -> Self { + Self::default().without_ctrl_chars() + } + + pub fn with_timeout(&mut self, timeout: u64) -> Self { + self.timeout = Some(Duration::from_secs(timeout)); + self.clone() + } + + pub fn without_ctrl_chars(&mut self) -> Self { + self.strip_ctrl_chars = true; + self.clone() + } + + pub fn with_askpass(&mut self) -> Self { + self.askpass = true; + self.clone() + } + + pub fn askpass(&self) -> bool { + self.askpass + } + + pub fn timeout(&self) -> Option { + self.timeout + } +} diff --git a/src/internal/config/up/utils/spinner_progress_handler.rs b/src/internal/config/up/utils/spinner_progress_handler.rs new file mode 100644 index 00000000..08eb1c0a --- /dev/null +++ b/src/internal/config/up/utils/spinner_progress_handler.rs @@ -0,0 +1,161 @@ +use std::cell::Cell; + +use indicatif::MultiProgress; +use indicatif::ProgressBar; +use indicatif::ProgressDrawTarget; +use indicatif::ProgressStyle; +use tokio::time::Duration; + +use crate::internal::config::up::utils::ProgressHandler; +use crate::internal::user_interface::ensure_newline_from_len; +use crate::internal::user_interface::print::strip_ansi_codes; +use crate::internal::user_interface::StringColor; + +#[derive(Debug, Clone)] +pub struct SpinnerProgressHandler { + spinner: ProgressBar, + template: String, + ensure_newline: bool, + base_len: usize, + last_message_len: Cell, +} + +impl SpinnerProgressHandler { + pub fn new(desc: String, progress: Option<(usize, usize)>) -> Self { + Self::new_with_params(desc, progress, None) + } + + pub fn new_with_multi( + desc: String, + progress: Option<(usize, usize)>, + multiprogress: MultiProgress, + ) -> Self { + Self::new_with_params(desc, progress, Some(multiprogress)) + } + + pub fn new_with_params( + desc: String, + progress: Option<(usize, usize)>, + multiprogress: Option, + ) -> Self { + let template = format!("{{prefix}}{} {} {{msg}}", "{spinner}".yellow(), desc,); + + // Base len = length of the description + 3 (1 for the spinner and 2 for the spaces) + let mut base_len = desc.len() + 4; + + let mut ensure_newline = true; + let spinner = if let Some(multiprogress) = multiprogress { + ensure_newline = false; + multiprogress.add(ProgressBar::new_spinner()) + } else { + ProgressBar::new_spinner() + }; + spinner.set_style( + ProgressStyle::default_spinner() + .template(template.as_str()) + .unwrap(), + ); + spinner.set_message("-"); + spinner.enable_steady_tick(Duration::from_millis(50)); + + if let Some((current, total)) = progress { + let padding = format!("{}", total).len(); + spinner.set_prefix( + format!( + "[{:padding$}/{:padding$}] ", + current, + total, + padding = padding + ) + .bold() + .light_black(), + ); + // Increase the base length to account for the prefix, which is the padding + // length times two (current and total) + the 2 brackets + the slash + the space + base_len += padding * 2 + 4; + } + + SpinnerProgressHandler { + spinner, + template, + ensure_newline, + base_len, + last_message_len: Cell::new(0), + } + } + + fn replace_spinner(&self, replace_by: String) { + let template = self.template.replace("{spinner}", replace_by.as_str()); + self.spinner.set_style( + ProgressStyle::default_spinner() + .template(template.as_str()) + .unwrap(), + ); + } + + fn update_last_message(&self, message: &str) { + let message = strip_ansi_codes(message); + let len = message.len(); + self.last_message_len.set(len); + } + + fn cur_len(&self) -> usize { + self.base_len + self.last_message_len.get() + } + + fn ensure_newline(&self) { + if self.ensure_newline { + ensure_newline_from_len(self.cur_len()); + } + } +} + +impl ProgressHandler for SpinnerProgressHandler { + fn println(&self, message: String) { + self.spinner.println(message); + } + + fn progress(&self, message: String) { + self.update_last_message(&message); + self.spinner.set_message(message); + } + + fn success(&self) { + let message = "done".to_string(); + self.update_last_message(&message); + // self.replace_spinner("✔".green()); + // self.spinner.finish(); + self.success_with_message(message); + self.ensure_newline(); + } + + fn success_with_message(&self, message: String) { + self.update_last_message(&message); + self.replace_spinner("✔".green()); + self.spinner.finish_with_message(message); + self.ensure_newline(); + } + + fn error(&self) { + let message = self.spinner.message().to_string(); + self.update_last_message(&message); + self.replace_spinner("✖".red()); + self.spinner.finish_with_message(message.red()); + self.ensure_newline(); + } + + fn error_with_message(&self, message: String) { + self.update_last_message(&message); + self.replace_spinner("✖".red()); + self.spinner.finish_with_message(message.red()); + self.ensure_newline(); + } + + fn hide(&self) { + self.spinner.set_draw_target(ProgressDrawTarget::hidden()); + } + + fn show(&self) { + self.spinner.set_draw_target(ProgressDrawTarget::stderr()); + } +} diff --git a/src/internal/config/up/utils/up_progress_handler.rs b/src/internal/config/up/utils/up_progress_handler.rs new file mode 100644 index 00000000..14c02cc8 --- /dev/null +++ b/src/internal/config/up/utils/up_progress_handler.rs @@ -0,0 +1,133 @@ +use once_cell::sync::OnceCell; + +use crate::internal::config::up::utils::PrintProgressHandler; +use crate::internal::config::up::utils::ProgressHandler; +use crate::internal::config::up::utils::SpinnerProgressHandler; +use crate::internal::env::shell_is_interactive; + +pub struct UpProgressHandler<'a> { + handler: OnceCell>, + step: Option<(usize, usize)>, + prefix: String, + parent: Option<&'a UpProgressHandler<'a>>, + allow_ending: bool, +} + +impl<'a> UpProgressHandler<'a> { + pub fn new(progress: Option<(usize, usize)>) -> Self { + UpProgressHandler { + handler: OnceCell::new(), + step: progress, + prefix: "".to_string(), + parent: None, + allow_ending: true, + } + } + + pub fn init(&self, desc: String) -> bool { + if self.handler.get().is_some() || self.parent.is_some() { + return false; + } + let boxed_handler: Box = if shell_is_interactive() { + Box::new(SpinnerProgressHandler::new(desc, self.step)) + } else { + Box::new(PrintProgressHandler::new(desc, self.step)) + }; + if self.handler.set(boxed_handler).is_err() { + panic!("failed to set progress handler"); + } + true + } + + fn handler(&self) -> &dyn ProgressHandler { + if let Some(parent) = self.parent { + return parent.handler(); + } + + self.handler + .get_or_init(|| { + let desc = "".to_string(); + let boxed_handler: Box = if shell_is_interactive() { + Box::new(SpinnerProgressHandler::new(desc, self.step)) + } else { + Box::new(PrintProgressHandler::new(desc, self.step)) + }; + boxed_handler + }) + .as_ref() + } + + pub fn subhandler(&'a self, prefix: &dyn ToString) -> UpProgressHandler<'a> { + UpProgressHandler { + handler: OnceCell::new(), + step: None, + prefix: prefix.to_string(), + parent: Some(self), + allow_ending: false, + } + } + + pub fn step(&self) -> Option<(usize, usize)> { + if let Some(parent) = self.parent { + parent.step() + } else { + self.step + } + } + + fn format_message(&self, message: String) -> String { + let message = format!("{}{}", self.prefix, message); + match self.parent { + Some(parent) => parent.format_message(message), + None => message, + } + } +} + +impl ProgressHandler for UpProgressHandler<'_> { + fn progress(&self, message: String) { + let message = self.format_message(message); + self.handler().progress(message); + } + + fn success(&self) { + self.handler().success(); + } + + fn success_with_message(&self, message: String) { + let message = self.format_message(message); + if self.allow_ending { + self.handler().success_with_message(message); + } else { + self.handler().progress(message); + } + } + + fn error(&self) { + if self.allow_ending { + self.handler().error(); + } + } + + fn error_with_message(&self, message: String) { + let message = self.format_message(message); + if self.allow_ending { + self.handler().error_with_message(message); + } else { + self.handler().progress(message); + } + } + + fn hide(&self) { + self.handler().hide(); + } + + fn show(&self) { + self.handler().show(); + } + + fn println(&self, message: String) { + let message = self.format_message(message); + self.handler().println(message); + } +} diff --git a/src/internal/config/up/utils/void_progress_handler.rs b/src/internal/config/up/utils/void_progress_handler.rs new file mode 100644 index 00000000..846638c5 --- /dev/null +++ b/src/internal/config/up/utils/void_progress_handler.rs @@ -0,0 +1,38 @@ +use crate::internal::config::up::utils::ProgressHandler; + +#[derive(Debug, Clone)] +pub struct VoidProgressHandler {} + +impl ProgressHandler for VoidProgressHandler { + fn println(&self, _message: String) { + // do nothing + } + + fn progress(&self, _message: String) { + // do nothing + } + + fn success(&self) { + // do nothing + } + + fn success_with_message(&self, _message: String) { + // do nothing + } + + fn error(&self) { + // do nothing + } + + fn error_with_message(&self, _message: String) { + // do nothing + } + + fn hide(&self) { + // do nothing + } + + fn show(&self) { + // do nothing + } +} diff --git a/src/internal/self_updater.rs b/src/internal/self_updater.rs index 9761298a..98534bd8 100644 --- a/src/internal/self_updater.rs +++ b/src/internal/self_updater.rs @@ -75,6 +75,24 @@ lazy_static! { }; } +pub fn compatible_release_arch() -> Vec { + if *RELEASE_ARCH == "x86_64" { + vec!["x86_64".to_string(), "amd64".to_string(), "x64".to_string()] + } else if *RELEASE_ARCH == "arm64" { + vec!["arm64".to_string(), "aarch64".to_string()] + } else { + vec![(*RELEASE_ARCH).to_string()] + } +} + +pub fn compatible_release_os() -> Vec { + if *RELEASE_OS == "darwin" { + vec!["darwin".to_string(), "macos".to_string()] + } else { + vec![(*RELEASE_OS).to_string()] + } +} + pub fn self_update() { // Check if OMNI_SKIP_SELF_UPDATE is set if let Some(skip_self_update) = std::env::var_os("OMNI_SKIP_SELF_UPDATE") { diff --git a/src/main.rs b/src/main.rs index 14675a4a..bf0fbb1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use internal::commands::HookEnvCommand; use internal::commands::HookInitCommand; use internal::commands::HookUuidCommand; use internal::config::ensure_bootstrap; -use internal::config::up::askpass::AskPassRequest; +use internal::config::up::utils::AskPassRequest; use internal::env::tmpdir_cleanup; use internal::git::auto_update_async; use internal::git::auto_update_sync; diff --git a/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md b/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md new file mode 100644 index 00000000..16d6a143 --- /dev/null +++ b/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md @@ -0,0 +1,109 @@ +--- +description: Configuration of the `github-release` kind of `up` parameter +--- + +# `github-release` operation + +Install a tool from a GitHub release. + +For this to work properly for a GitHub release, it will need to: +- Be provided as a `.tar.gz` or `.zip` archive +- Have a file name that contains hints about the OS it was built for (e.g. `linux`, `darwin`, ...) +- Have a file name that contains hints about the architecture it was built for (e.g. `amd64`, `arm64`, ...) + +Omni will download all the assets matching the current OS and architecture, extract them and move all the found binary files to a known location to be loaded in the repository environment. + +:::note +This does not support using authentication yet, and thus will only work for public repositories for now. +::: + +## Alternative names + +- `ghrelease` +- `github_release` +- `githubrelease` + +## Parameters + +| Parameter | Type | Description | +|------------------|-----------|-------------------------------------------------------| +| `repository` | string | The name of the repository to download the release from, in the `/` format; can also be provided as an object with the `owner` and `name` keys | +| `version` | string | The version of the tool to install; see [version handling](#version-handling) below for more details. | +| `prerelease` | boolean | Whether to download a prerelease version or only match stable releases | +| `api_url` | string | The URL of the GitHub API to use, useful to use GitHub Enterprise (e.g. `https://github.example.com/api/v3`); defaults to `https://api.github.com` | + +### Version handling + +The following strings can be used to specify the version: + +| Version | Meaning | +|---------|---------| +| `1.2` | Accepts `1.2` and any version prefixed by `1.2.*` | +| `1.2.3` | Accepts `1.2.3` and any version prefixed by `1.2.3.*` | +| `~1.2.3` | Accepts `1.2.3` and higher patch versions (`1.2.4`, `1.2.5`, etc. but not `1.3.0`) | +| `^1.2.3` | Accepts `1.2.3` and higher minor and patch versions (`1.2.4`, `1.3.1`, `1.4.7`, etc. but not `2.0.0`) | +| `>1.2.3` | Must be greater than `1.2.3` | +| `>=1.2.3` | Must be greater or equal to `1.2.3` | +| `<1.2.3` | Must be lower than `1.2.3` | +| `<=1.2.3` | Must be lower or equal to `1.2.3` | +| `1.2.x` | Accepts `1.2.0`, `1.2.1`, etc. but will not accept `1.3.0` | +| `*` | Matches any version (will default to `latest`) | +| `latest` | Latest release | +| `auto` | Lookup for any version files in the project directory (`.tool-versions`, `.go-version`, `.golang-version` or `.go.mod`) and apply version parsing | + +The version also supports the `||` operator to specify ranges. This operator is not compatible with the `latest` and `auto` keywords. For instance, `1.2.x || >1.3.5 <=1.4.0` will match any version between `1.2.0` included and `1.3.0` excluded, or between `1.3.5` excluded and `1.4.0` included. + +The latest version satisfying the requirements will be installed. + +## Examples + +```yaml +up: + # Will error out since no repository is provided + - github-release + + # Will install the latest release of the `omni` tool + # from the `XaF/omni` repository + - github-release: XaF/omni + + # We can call it with any of the alternative names too + - ghrelease: XaF/omni + - github_release: XaF/omni + - githubrelease: XaF/omni + + # Will also install the latest version + - github-release: + repository: XaF/omni + version: latest + + # Will install any version starting with 1.20 + - github-release: + repository: XaF/omni + version: 1.2 + + # Will install any version starting with 1 + - github-release: + repository: XaF/omni + version: 1 + + # Full specification of the parameter to identify the version; + # this will install any version starting with 1.2.3 + - github-release: + repository: XaF/omni + version: 1.2.3 + + # Will install any version starting with 1, including + # any pre-release versions + - github-release: + repository: XaF/omni + version: 1 + prerelease: true +``` + +## Dynamic environment + +The following variables will be set as part of the [dynamic environment](/reference/dynamic-environment). + +| Environment variable | Operation | Description | +|----------------------|-----------|-------------| +| `PATH` | prepend | Injects the path to the binaries of the installed tool | diff --git a/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-self.md b/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-self.md index dfd95ae7..9279fa84 100644 --- a/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-self.md +++ b/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-self.md @@ -20,6 +20,7 @@ Each entry in the list can either be a single string (loads the operation with d | `bundler` | [bundler](up/bundler) | Install dependencies with bundler | | `custom` | [custom](up/custom) | A custom, user-defined operation | | `dnf` | [dnf](up/dnf) | Install packages with `dnf` for fedora-based systems | +| `github-release` | [github-release](up/github-release) | Install a tool from a GitHub release | | `go` | [go](up/go) | Install go | | `homebrew` | [Homebrew](up/homebrew) | Install formulae and casks with homebrew | | `nix` | [nix](up/nix) | Install packages with `nix` |