From 0bcdf72876622a900b0ddac50197a38a7122861b Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:20:17 -0600 Subject: [PATCH] feat: aqua backend (#2995) --- Cargo.lock | 21 ++ Cargo.toml | 4 +- docs/.vitepress/config.ts | 1 + docs/dev-tools/backends/aqua.md | 25 ++ docs/dev-tools/backends/vfox.md | 15 +- docs/faq.md | 5 +- docs/registry.md | 10 +- e2e/backend/test_aqua | 6 + registry.toml | 10 +- schema/mise.json | 5 + scripts/render-registry.js | 3 + settings.toml | 6 + src/aqua/aqua_registry.rs | 182 ++++++++++++ src/aqua/aqua_template.rs | 88 ++++++ src/aqua/mod.rs | 2 + src/backend/aqua.rs | 267 ++++++++++++++++++ src/backend/mod.rs | 8 +- ...i__backends__ls__tests__backends_list.snap | 1 + src/cli/settings/ls.rs | 2 + src/cli/settings/set.rs | 1 + src/cli/settings/unset.rs | 1 + src/file.rs | 17 +- src/github.rs | 8 + src/main.rs | 2 + src/maplit.rs | 17 ++ src/plugins/core/go.rs | 2 +- src/plugins/core/java.rs | 2 +- src/plugins/core/node.rs | 4 +- src/plugins/core/python.rs | 2 +- src/registry.rs | 5 +- src/shims.rs | 1 + src/toolset/mod.rs | 1 + 32 files changed, 689 insertions(+), 35 deletions(-) create mode 100644 docs/dev-tools/backends/aqua.md create mode 100644 e2e/backend/test_aqua create mode 100644 src/aqua/aqua_registry.rs create mode 100644 src/aqua/aqua_template.rs create mode 100644 src/aqua/mod.rs create mode 100644 src/backend/aqua.rs create mode 100644 src/maplit.rs diff --git a/Cargo.lock b/Cargo.lock index 906bb8f699..ec7173ca40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2064,6 +2064,7 @@ dependencies = [ "serde_derive", "serde_ignored", "serde_json", + "serde_yaml", "sevenz-rust", "sha2", "shell-escape", @@ -2091,6 +2092,7 @@ dependencies = [ "walkdir", "which 7.0.0", "xx", + "xz2", "zip", ] @@ -3157,6 +3159,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.6.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sevenz-rust" version = "0.6.1" @@ -3986,6 +4001,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 41644d410c..fbbc980611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,8 @@ rmp-serde = "1" serde = "1" serde_derive = "1" serde_ignored = "0.1" -serde_json = { version = "1", features = [] } +serde_json = "1" +serde_yaml = "0.9" sha2 = "0.10.8" shell-escape = "0.1" shell-words = "1" @@ -129,6 +130,7 @@ vfox = { version = "0.3", default-features = false } walkdir = "2" which = "7" xx = { version = "1", features = ["glob"] } +xz2 = "0.1" zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e6301094a1..52cd3183e5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -75,6 +75,7 @@ export default defineConfig({ link: "/dev-tools/backends/", collapsed: true, items: [ + { text: "aqua", link: "/dev-tools/backends/aqua" }, { text: "asdf", link: "/dev-tools/backends/asdf" }, { text: "cargo", link: "/dev-tools/backends/cargo" }, { text: "go", link: "/dev-tools/backends/go" }, diff --git a/docs/dev-tools/backends/aqua.md b/docs/dev-tools/backends/aqua.md new file mode 100644 index 0000000000..25ba3952e0 --- /dev/null +++ b/docs/dev-tools/backends/aqua.md @@ -0,0 +1,25 @@ +# Aqua Backend + +[Aqua](https://aquaproj.github.io/) tools may be used natively in mise. + +The code for this is inside the mise repository at [`./src/backend/aqua.rs`](https://github.com/jdx/mise/blob/main/src/backend/aqua.rs). + +## Usage + +The following installs the latest version of ripgrep and sets it as the active version on PATH: + +```sh +$ mise use -g aqua:BurntSushi/ripgrep +$ rg --version +ripgrep 14.1.1 +``` + +The version will be set in `~/.config/mise/config.toml` with the following format: + +```toml +[tools] +"aqua:BurntSushi/ripgrep" = "latest" +``` + +Some tools will default to use aqua if they're specified in [registry.toml](https://github.com/jdx/mise/blob/main/registry.toml) +to use the aqua backend. To see these tools, run `mise registry | grep aqua:`. diff --git a/docs/dev-tools/backends/vfox.md b/docs/dev-tools/backends/vfox.md index 8e0fafa358..ce293a3f6e 100644 --- a/docs/dev-tools/backends/vfox.md +++ b/docs/dev-tools/backends/vfox.md @@ -1,9 +1,8 @@ # Vfox Backend -[Vfox](https://github.com/version-fox/vfox) plugins may be used in mise as an alternative for asdf -plugins. On Windows, only vfox plugins are supported since asdf plugins require POSIX compatibility. +[Vfox](https://github.com/version-fox/vfox) plugins may be used in mise to install tools. -The code for this is inside of the mise repository at [`./src/backend/vfox.rs`](https://github.com/jdx/mise/blob/main/src/backend/vfox.rs). +The code for this is inside the mise repository at [`./src/backend/vfox.rs`](https://github.com/jdx/mise/blob/main/src/backend/vfox.rs). ## Dependencies @@ -13,14 +12,6 @@ No dependencies are required for vfox. Vfox lua code is read via a lua interpret The following installs the latest version of cmake and sets it as the active version on PATH: -```sh -$ mise use -g vfox:cmake -$ cmake --version -cmake version 3.21.3 -``` - -Alternatively, you can specify the GitHub repo: - ```sh $ mise use -g vfox:version-fox/vfox-cmake $ cmake --version @@ -31,7 +22,7 @@ The version will be set in `~/.config/mise/config.toml` with the following forma ```toml [tools] -"vfox:cmake" = "latest" +"vfox:version-fox/vfox-cmake" = "latest" ``` ## Default plugin backend diff --git a/docs/faq.md b/docs/faq.md index 976ff9112d..5bc2c69823 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -77,9 +77,8 @@ and `mise deactivate` to work without wrapping them in `eval "$(mise shell)"`. ## Windows support? -Very basic support for windows is currently available, however because Windows can't support asdf -plugins, they must use core and vfox only—which means only a handful of tools are available on -Windows. +Very basic support for windows is currently available, however because Windows can't support the asdf +backend, windows can use core, aqua, or vfox backends though. As of this writing, env var management and task execution are not yet supported on Windows. diff --git a/docs/registry.md b/docs/registry.md index c47e152665..362416a082 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -8,12 +8,12 @@ editLink: false | ----------- | --------------- | | 1password-cli | [asdf:NeoHsu/asdf-1password-cli](https://github.com/NeoHsu/asdf-1password-cli) | | aapt2 | [asdf:ronnnnn/asdf-aapt2](https://github.com/ronnnnn/asdf-aapt2) | -| act | [ubi:nektos/act](https://github.com/nektos/act) [asdf:gr1m0h/asdf-act](https://github.com/gr1m0h/asdf-act) | -| action-validator | [ubi:mpalmer/action-validator](https://github.com/mpalmer/action-validator) [asdf:mpalmer/action-validator](https://github.com/mpalmer/action-validator) | +| act | [aqua:nektos/act](https://github.com/nektos/act) [ubi:nektos/act](https://github.com/nektos/act) [asdf:gr1m0h/asdf-act](https://github.com/gr1m0h/asdf-act) | +| action-validator | [aqua:mpalmer/action-validator](https://github.com/mpalmer/action-validator) [ubi:mpalmer/action-validator](https://github.com/mpalmer/action-validator) [asdf:mpalmer/action-validator](https://github.com/mpalmer/action-validator) | | actionlint | [ubi:rhysd/actionlint](https://github.com/rhysd/actionlint) [asdf:crazy-matt/asdf-actionlint](https://github.com/crazy-matt/asdf-actionlint) | | adr-tools | [asdf:https://gitlab.com/td7x/asdf/adr-tools](https://gitlab.com/td7x/asdf/adr-tools) | | ag | [asdf:koketani/asdf-ag](https://github.com/koketani/asdf-ag) | -| age | [asdf:threkk/asdf-age](https://github.com/threkk/asdf-age) | +| age | [aqua:FiloSottile/age](https://github.com/FiloSottile/age) [asdf:threkk/asdf-age](https://github.com/threkk/asdf-age) | | age-plugin-yubikey | [asdf:joke/asdf-age-plugin-yubikey](https://github.com/joke/asdf-age-plugin-yubikey) | | agebox | [ubi:slok/agebox](https://github.com/slok/agebox) [asdf:slok/asdf-agebox](https://github.com/slok/asdf-agebox) | | air | [asdf:pdemagny/asdf-air](https://github.com/pdemagny/asdf-air) | @@ -179,7 +179,7 @@ editLink: false | dhall | [asdf:aaaaninja/asdf-dhall](https://github.com/aaaaninja/asdf-dhall) | | difftastic | [ubi:wilfred/difftastic](https://github.com/wilfred/difftastic) [asdf:volf52/asdf-difftastic](https://github.com/volf52/asdf-difftastic) | | digdag | [asdf:jtakakura/asdf-digdag](https://github.com/jtakakura/asdf-digdag) | -| direnv | [asdf:asdf-community/asdf-direnv](https://github.com/asdf-community/asdf-direnv) | +| direnv | [aqua:direnv/direnv](https://github.com/direnv/direnv) [asdf:asdf-community/asdf-direnv](https://github.com/asdf-community/asdf-direnv) | | dive | [ubi:wagoodman/dive](https://github.com/wagoodman/dive) [asdf:looztra/asdf-dive](https://github.com/looztra/asdf-dive) | | djinni | [asdf:cross-language-cpp/asdf-djinni](https://github.com/cross-language-cpp/asdf-djinni) | | dmd | [asdf:sylph01/asdf-dmd](https://github.com/sylph01/asdf-dmd) | @@ -612,7 +612,7 @@ editLink: false | revive | [asdf:bjw-s/asdf-revive](https://github.com/bjw-s/asdf-revive) | | richgo | [asdf:paxosglobal/asdf-richgo](https://github.com/paxosglobal/asdf-richgo) | | riff | [asdf:abinet/asdf-riff](https://github.com/abinet/asdf-riff) | -| ripgrep | [ubi:BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep) [asdf:https://gitlab.com/wt0f/asdf-ripgrep](https://gitlab.com/wt0f/asdf-ripgrep) | +| ripgrep | [ubi:BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep) [aqua:BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep) [asdf:https://gitlab.com/wt0f/asdf-ripgrep](https://gitlab.com/wt0f/asdf-ripgrep) | | rke | [asdf:particledecay/asdf-rke](https://github.com/particledecay/asdf-rke) | | rlwrap | [asdf:asdf-community/asdf-rlwrap](https://github.com/asdf-community/asdf-rlwrap) | | rome | [asdf:kichiemon/asdf-rome](https://github.com/kichiemon/asdf-rome) | diff --git a/e2e/backend/test_aqua b/e2e/backend/test_aqua new file mode 100644 index 0000000000..25305df7e8 --- /dev/null +++ b/e2e/backend/test_aqua @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +export MISE_EXPERIMENTAL=1 + +assert_contains "mise x aqua:BurntSushi/ripgrep@14.0.0 -- rg --version" "ripgrep 14.0.0" +assert "mise x age@1.2.0 -- age --version" "v1.2.0" diff --git a/registry.toml b/registry.toml index 82c8171592..e9ab236db8 100644 --- a/registry.toml +++ b/registry.toml @@ -7,12 +7,12 @@ [tools] 1password-cli = ["asdf:NeoHsu/asdf-1password-cli"] aapt2 = ["asdf:ronnnnn/asdf-aapt2"] -act = ["ubi:nektos/act", "asdf:gr1m0h/asdf-act"] -action-validator = ["ubi:mpalmer/action-validator", "asdf:mpalmer/action-validator"] +act = ["aqua:nektos/act", "ubi:nektos/act", "asdf:gr1m0h/asdf-act"] +action-validator = ["aqua:mpalmer/action-validator", "ubi:mpalmer/action-validator", "asdf:mpalmer/action-validator"] actionlint = ["ubi:rhysd/actionlint", "asdf:crazy-matt/asdf-actionlint"] adr-tools = ["asdf:https://gitlab.com/td7x/asdf/adr-tools"] ag = ["asdf:koketani/asdf-ag"] -age = ["asdf:threkk/asdf-age"] +age = ["aqua:FiloSottile/age", "asdf:threkk/asdf-age"] age-plugin-yubikey = ["asdf:joke/asdf-age-plugin-yubikey"] agebox = ["ubi:slok/agebox", "asdf:slok/asdf-agebox"] air = ["asdf:pdemagny/asdf-air"] @@ -178,7 +178,7 @@ devspace = ["asdf:NeoHsu/asdf-devspace"] dhall = ["asdf:aaaaninja/asdf-dhall"] difftastic = ["ubi:wilfred/difftastic[exe=difft]", "asdf:volf52/asdf-difftastic"] digdag = ["asdf:jtakakura/asdf-digdag"] -direnv = ["asdf:asdf-community/asdf-direnv"] +direnv = ["aqua:direnv/direnv", "asdf:asdf-community/asdf-direnv"] dive = ["ubi:wagoodman/dive", "asdf:looztra/asdf-dive"] djinni = ["asdf:cross-language-cpp/asdf-djinni"] dmd = ["asdf:sylph01/asdf-dmd"] @@ -610,7 +610,7 @@ restish = ["ubi:danielgtaylor/restish", "go:github.com/danielgtaylor/restish"] revive = ["asdf:bjw-s/asdf-revive"] richgo = ["asdf:paxosglobal/asdf-richgo"] riff = ["asdf:abinet/asdf-riff"] -ripgrep = ["ubi:BurntSushi/ripgrep[exe=rg]", "asdf:https://gitlab.com/wt0f/asdf-ripgrep"] +ripgrep = ["ubi:BurntSushi/ripgrep[exe=rg]", "aqua:BurntSushi/ripgrep", "asdf:https://gitlab.com/wt0f/asdf-ripgrep"] rke = ["asdf:particledecay/asdf-rke"] rlwrap = ["asdf:asdf-community/asdf-rlwrap"] rome = ["asdf:kichiemon/asdf-rome"] diff --git a/schema/mise.json b/schema/mise.json index 1177e2c0a9..6fce7fa084 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -127,6 +127,11 @@ "description": "should mise keep install files after installation even if the installation fails", "type": "boolean" }, + "aqua_registry_url": { + "default": "https://github.com/aquaproj/aqua-registry", + "description": "URL to fetch aqua registry from.", + "type": "string" + }, "asdf": { "description": "use asdf as a default plugin backend", "deprecated": "Use disable_backends instead.", diff --git a/scripts/render-registry.js b/scripts/render-registry.js index b0d1c65c2e..3c65058240 100755 --- a/scripts/render-registry.js +++ b/scripts/render-registry.js @@ -44,6 +44,9 @@ for (const match of stdout.split("\n")) { return `[${match[1]}:${match[2]}](https://pkg.go.dev/${match[2]})`; } else if (match[1] === "ubi") { return `[${match[1]}:${match[2]}](https://github.com/${match[2]})`; + } else if (match[1] === "aqua") { + // TODO: handle non-github repos + return `[${match[1]}:${match[2]}](https://github.com/${match[2]})`; } else { throw new Error(`Unknown registry: ${full}`); } diff --git a/settings.toml b/settings.toml index 0eaf01abe2..aa44ff58d3 100644 --- a/settings.toml +++ b/settings.toml @@ -55,6 +55,12 @@ env = "MISE_ALWAYS_KEEP_INSTALL" type = "Bool" description = "should mise keep install files after installation even if the installation fails" +[aqua_registry_url] +env = "MISE_AQUA_REGISTRY_URL" +type = "Url" +default = "https://github.com/aquaproj/aqua-registry" +description = "URL to fetch aqua registry from." + [asdf] env = "MISE_ASDF" type = "Bool" diff --git a/src/aqua/aqua_registry.rs b/src/aqua/aqua_registry.rs new file mode 100644 index 0000000000..507e0f05ff --- /dev/null +++ b/src/aqua/aqua_registry.rs @@ -0,0 +1,182 @@ +use crate::backend::aqua; +use crate::config::SETTINGS; +use crate::duration::DAILY; +use crate::git::Git; +use crate::{dirs, file}; +use eyre::Result; +use itertools::Itertools; +use once_cell::sync::Lazy; +use serde_derive::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +pub static AQUA_REGISTRY: Lazy = Lazy::new(|| { + AquaRegistry::standard().unwrap_or_else(|err| { + warn!("failed to initialize aqua registry: {err:?}"); + AquaRegistry::default() + }) +}); +static AQUA_REGISTRY_PATH: Lazy = Lazy::new(|| dirs::CACHE.join("aqua-registry")); + +#[derive(Default)] +pub struct AquaRegistry { + path: PathBuf, +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "snake_case")] +pub enum AquaPackageType { + #[default] + GithubRelease, + Http, +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(default)] +pub struct AquaPackage { + pub r#type: AquaPackageType, + pub repo_owner: String, + pub repo_name: String, + pub asset: String, + pub url: String, + pub description: Option, + pub format: String, + pub rosetta2: bool, + pub windows_arm_emulation: bool, + pub complete_windows_ext: bool, + pub supported_envs: Vec, + pub files: Vec, + pub replacements: HashMap, + overrides: Vec, + version_constraint: String, + version_overrides: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct AquaOverride { + #[serde(flatten)] + pkg: AquaPackage, + goos: Option, + goarch: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AquaFile { + // pub name: String, + pub src: Option, +} + +#[derive(Debug, Deserialize)] +struct RegistryYaml { + packages: Vec, +} + +impl AquaRegistry { + pub fn standard() -> Result { + let path = AQUA_REGISTRY_PATH.clone(); + let repo = Git::new(&path); + if repo.exists() { + fetch_latest_repo(&repo)?; + } else { + info!("cloning aqua registry to {path:?}"); + repo.clone(&SETTINGS.aqua_registry_url)?; + } + Ok(Self { path }) + } + + pub fn package(&self, id: &str) -> Result> { + let path_id = id.split('/').join(std::path::MAIN_SEPARATOR_STR); + let path = self.path.join("pkgs").join(path_id).join("registry.yaml"); + if !path.exists() { + return Ok(None); + } + let f = file::open(&path)?; + let registry: RegistryYaml = serde_yaml::from_reader(f)?; + Ok(registry.packages.into_iter().next()) + } + + pub fn package_with_version(&self, id: &str, v: &str) -> Result> { + if let Some(pkg) = self.package(id)? { + Ok(Some(pkg.with_version(v))) + } else { + Ok(None) + } + } +} + +fn fetch_latest_repo(repo: &Git) -> Result<()> { + if file::modified_duration(&repo.dir)? < DAILY { + return Ok(()); + } + info!("updating aqua registry repo"); + repo.update(None)?; + Ok(()) +} + +impl AquaPackage { + fn with_version(mut self, v: &str) -> AquaPackage { + if let Some(avo) = self.version_override(v).cloned() { + self = apply_override(self, &avo) + } + if let Some(avo) = self.overrides.clone().into_iter().find(|o| { + if let (Some(goos), Some(goarch)) = (&o.goos, &o.goarch) { + goos == aqua::os() && goarch == aqua::arch() + } else if let Some(goos) = &o.goos { + goos == aqua::os() + } else if let Some(goarch) = &o.goarch { + goarch == aqua::arch() + } else { + false + } + }) { + self = apply_override(self, &avo.pkg) + } + self + } + + fn version_override(&self, _v: &str) -> Option<&AquaPackage> { + self.version_overrides + .iter() + // TODO: semver + .find(|vo| vo.version_constraint == "true") + } +} + +fn apply_override(mut orig: AquaPackage, avo: &AquaPackage) -> AquaPackage { + if !avo.repo_owner.is_empty() { + orig.repo_owner = avo.repo_owner.clone(); + } + if !avo.repo_name.is_empty() { + orig.repo_name = avo.repo_name.clone(); + } + if !avo.asset.is_empty() { + orig.asset = avo.asset.clone(); + } + if !avo.url.is_empty() { + orig.url = avo.url.clone(); + } + if !avo.format.is_empty() { + orig.format = avo.format.clone(); + } + if avo.rosetta2 { + orig.rosetta2 = true; + } + if avo.windows_arm_emulation { + orig.windows_arm_emulation = true; + } + if avo.complete_windows_ext { + orig.complete_windows_ext = true; + } + if !avo.supported_envs.is_empty() { + orig.supported_envs = avo.supported_envs.clone(); + } + if !avo.files.is_empty() { + orig.files = avo.files.clone(); + } + orig.replacements.extend(avo.replacements.clone()); + if !avo.overrides.is_empty() { + orig.overrides = avo.overrides.clone(); + } + + orig +} diff --git a/src/aqua/aqua_template.rs b/src/aqua/aqua_template.rs new file mode 100644 index 0000000000..da32101c15 --- /dev/null +++ b/src/aqua/aqua_template.rs @@ -0,0 +1,88 @@ +use heck::ToTitleCase; +use itertools::Itertools; +use std::collections::HashMap; + +type Context = HashMap; + +pub fn render(tmpl: &str, ctx: &Context) -> String { + let mut result = String::new(); + let mut in_tag = false; + let mut tag = String::new(); + let chars = tmpl.chars().collect_vec(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let next = chars.get(i + 1).cloned().unwrap_or(' '); + if !in_tag && c == '{' && next == '{' { + in_tag = true; + i += 1; + } else if in_tag && c == '}' && next == '}' { + in_tag = false; + result += &parse(&tag, ctx); + tag.clear(); + i += 1; + } else if in_tag { + tag.push(c); + } else { + result.push(c); + } + i += 1; + } + result +} + +fn parse(mut code: &str, ctx: &Context) -> String { + let mut ops = vec![]; + if code.starts_with("title ") { + code = &code[6..]; + ops.push(|s: &str| s.to_title_case()); + } + let mut val = if let Some(key) = code.strip_prefix(".") { + ctx.get(key).unwrap().clone() + } else if code.starts_with('"') && code.ends_with('"') { + // TODO: handle quotes in the middle of code + code[1..code.len() - 1].to_string() + } else { + warn!("unable to parse aqua template: {code}"); + "".to_string() + }; + + for op in ops.into_iter().rev() { + val = op(&val); + } + + val +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hashmap; + + #[test] + fn test_render() { + let tmpl = "Hello, {{.OS}}!"; + let mut ctx = HashMap::new(); + ctx.insert("OS".to_string(), "world".to_string()); + assert_eq!(render(tmpl, &ctx), "Hello, world!"); + } + + macro_rules! parse_tests { + ($($name:ident: $value:expr,)*) => { + $( + #[test] + fn $name() { + let (input, expected, ctx): (&str, &str, HashMap<&str, &str>) = $value; + let ctx = ctx.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + assert_eq!(expected, parse(input, &ctx)); + } + )* + }} + + parse_tests!( + test_parse1: (".OS", "world", hashmap!{"OS" => "world"}), + test_parse2: ("\"world\"", "world", hashmap!{}), + test_parse3: ("XXX", "", hashmap!{}), + test_parse4: (r#"title "world""#, "World", hashmap!{}), + ); +} diff --git a/src/aqua/mod.rs b/src/aqua/mod.rs new file mode 100644 index 0000000000..9a3e71664d --- /dev/null +++ b/src/aqua/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod aqua_registry; +pub(crate) mod aqua_template; diff --git a/src/backend/aqua.rs b/src/backend/aqua.rs new file mode 100644 index 0000000000..576bc2d585 --- /dev/null +++ b/src/backend/aqua.rs @@ -0,0 +1,267 @@ +use crate::aqua::aqua_registry::{AquaPackage, AquaPackageType, AQUA_REGISTRY}; +use crate::aqua::aqua_template; +use crate::backend::{Backend, BackendType}; +use crate::cache::{CacheManager, CacheManagerBuilder}; +use crate::cli::args::BackendArg; +use crate::cli::version::{ARCH, OS}; +use crate::config::SETTINGS; +use crate::http::HTTP; +use crate::install_context::InstallContext; +use crate::registry::REGISTRY; +use crate::toolset::ToolVersion; +use crate::{dirs, file, github, hashmap}; +use eyre::{bail, ContextCompat, Result}; +use itertools::Itertools; +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct AquaBackend { + ba: BackendArg, + id: String, + remote_version_cache: CacheManager>, +} + +impl Backend for AquaBackend { + fn get_type(&self) -> BackendType { + BackendType::Aqua + } + + fn fa(&self) -> &BackendArg { + &self.ba + } + + fn _list_remote_versions(&self) -> eyre::Result> { + self.remote_version_cache + .get_or_try_init(|| { + if let Some(pkg) = AQUA_REGISTRY.package(&self.id)? { + if !pkg.repo_owner.is_empty() && !pkg.repo_name.is_empty() { + Ok( + github::list_releases(&format!( + "{}/{}", + pkg.repo_owner, pkg.repo_name + ))? + .into_iter() + .map(|r| { + r.tag_name + .strip_prefix('v') + .unwrap_or(&r.tag_name) + .to_string() + }) + .rev() + .collect_vec(), + ) + } else { + warn!("no aqua registry found for {}", self.ba); + Ok(vec![]) + } + } else { + warn!("no aqua registry found for {}", self.ba); + Ok(vec![]) + } + }) + .cloned() + } + + fn install_version_impl(&self, ctx: &InstallContext) -> eyre::Result<()> { + if !cfg!(windows) { + SETTINGS.ensure_experimental("aqua")?; + } + let v = &ctx.tv.version; + let pkg = AQUA_REGISTRY + .package_with_version(&self.id, v)? + .wrap_err_with(|| format!("no aqua registry found for {}", self.id))?; + match pkg.r#type { + AquaPackageType::GithubRelease => { + Self::install_version_github_release(ctx, v, &pkg)?; + } + AquaPackageType::Http => { + unimplemented!("http aqua packages not yet supported") + } + } + + Ok(()) + } + + fn list_bin_paths(&self, tv: &ToolVersion) -> Result> { + let pkg = AQUA_REGISTRY + .package_with_version(&self.id, &tv.version)? + .wrap_err_with(|| format!("no aqua registry found for {}", self.ba))?; + + if pkg.files.is_empty() { + return Ok(vec![tv.install_path()]); + } + + Ok(pkg + .files + .iter() + .flat_map(|f| { + f.src + .as_ref() + .map(|s| parse_aqua_str(&pkg, s, &tv.version, &Default::default())) + }) + .map(|f| { + PathBuf::from(f) + .parent() + .map(|p| tv.install_path().join(p)) + .unwrap_or_else(|| tv.install_path()) + }) + .unique() + .collect()) + } +} + +impl AquaBackend { + pub fn from_arg(ba: BackendArg) -> Self { + let mut id = ba.full.strip_prefix("aqua:").unwrap_or(&ba.full); + if !id.contains("/") { + id = REGISTRY + .get(id) + .and_then(|b| b.iter().find_map(|s| s.strip_prefix("aqua:"))) + .unwrap_or_else(|| { + panic!("invalid aqua tool: {}", id); + }); + } + Self { + remote_version_cache: CacheManagerBuilder::new( + ba.cache_path.join("remote_versions.msgpack.z"), + ) + .with_fresh_duration(SETTINGS.fetch_remote_versions_cache()) + .with_fresh_file(dirs::DATA.to_path_buf()) + .with_fresh_file(ba.installs_path.to_path_buf()) + .build(), + id: id.to_string(), + ba, + } + } + + fn install_version_github_release( + ctx: &InstallContext, + v: &str, + pkg: &AquaPackage, + ) -> Result<()> { + validate(pkg)?; + let gh_id = format!("{}/{}", pkg.repo_owner, pkg.repo_name); + let mut v = format!("v{}", v); + let gh_release = match github::get_release(&gh_id, &v) { + Ok(r) => r, + Err(_) => { + v = v.strip_prefix('v').unwrap().to_string(); + github::get_release(&gh_id, &v)? + } + }; + let asset_strs = asset_strs(pkg, &v); + let asset = gh_release + .assets + .iter() + .find(|a| asset_strs.contains(&a.name)) + .wrap_err_with(|| { + format!( + "no asset found: {}\nAvailable assets:\n{}", + asset_strs.iter().join(", "), + gh_release.assets.iter().map(|a| &a.name).join("\n") + ) + })?; + + let url = &asset.browser_download_url; + let filename = url.split('/').last().unwrap(); + let tarball_path = ctx.tv.download_path().join(filename); + ctx.pr.set_message(format!("downloading {filename}")); + HTTP.download_file(url, &tarball_path, Some(ctx.pr.as_ref()))?; + + ctx.pr.set_message(format!("installing {filename}")); + let install_path = ctx.tv.install_path(); + file::remove_all(&install_path)?; + if pkg.format == "raw" { + file::create_dir_all(&install_path)?; + let bin_path = install_path.join(&pkg.repo_name); + file::copy(&tarball_path, &bin_path)?; + file::make_executable(&bin_path)?; + } else if pkg.format == "tar.gz" { + file::untar_gz(&tarball_path, &install_path)?; + } else if pkg.format == "tar.xz" { + file::untar_xz(&tarball_path, &install_path)?; + } else if pkg.format == "zip" { + file::unzip(&tarball_path, &install_path)?; + } else { + bail!("unsupported format: {}", pkg.format); + } + + Ok(()) + } +} + +fn validate(pkg: &AquaPackage) -> Result<()> { + let envs: HashSet<&str> = pkg.supported_envs.iter().map(|s| s.as_str()).collect(); + let os = os(); + let os_arch = format!("{}-{}", os, arch()); + if !(envs.is_empty() + || envs.contains("all") + || envs.contains(os) + || envs.contains(os_arch.as_str())) + { + bail!("unsupported env: {os_arch}"); + } + Ok(()) +} + +pub fn os() -> &'static str { + if cfg!(target_os = "macos") { + "darwin" + } else { + &OS + } +} + +pub fn arch() -> &'static str { + if cfg!(target_arch = "x86_64") { + "amd64" + } else if cfg!(target_arch = "arm") { + "armv6l" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + &ARCH + } +} + +fn asset_strs(pkg: &AquaPackage, v: &str) -> HashSet { + let mut ctx = Default::default(); + let mut strs = HashSet::from([parse_aqua_str(pkg, &pkg.asset, v, &ctx)]); + if cfg!(macos) { + ctx.insert("ARCH".to_string(), "arm64".to_string()); + strs.insert(parse_aqua_str(pkg, &pkg.asset, v, &ctx)); + } + strs +} + +fn parse_aqua_str( + pkg: &AquaPackage, + s: &str, + v: &str, + overrides: &HashMap, +) -> String { + let os = os(); + let mut arch = arch(); + if os == "darwin" && arch == "arm64" && pkg.rosetta2 { + arch = "amd64"; + } + if os == "windows" && arch == "arm64" && pkg.windows_arm_emulation { + arch = "amd64"; + } + let replace = |s: &str| { + pkg.replacements + .get(s) + .map(|s| s.to_string()) + .unwrap_or_else(|| s.to_string()) + }; + let mut ctx = hashmap! { + "Version".to_string() => replace(v), + "OS".to_string() => replace(os), + "Arch".to_string() => replace(arch), + "Format".to_string() => replace(&pkg.format), + }; + ctx.extend(overrides.clone()); + aqua_template::render(s, &ctx) +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 60d0a64911..3b3fe85a15 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -30,6 +30,7 @@ use crate::toolset::{is_outdated_version, ToolRequest, ToolSource, ToolVersion, use crate::ui::progress_report::SingleReport; use crate::{config, dirs, env, file, lock_file, versions_host}; +pub mod aqua; pub mod asdf; pub mod backend_meta; pub mod cargo; @@ -61,6 +62,7 @@ pub type VersionCacheManager = CacheManager>; )] #[strum(serialize_all = "snake_case")] pub enum BackendType { + Aqua, Asdf, Cargo, Core, @@ -163,6 +165,7 @@ pub fn get(fa: &BackendArg) -> ABackend { pub fn arg_to_backend(ba: BackendArg) -> ABackend { match ba.backend_type { + BackendType::Aqua => Arc::new(aqua::AquaBackend::from_arg(ba)), BackendType::Asdf => Arc::new(asdf::AsdfBackend::from_arg(ba)), BackendType::Cargo => Arc::new(cargo::CargoBackend::from_arg(ba)), BackendType::Core => Arc::new(asdf::AsdfBackend::from_arg(ba)), @@ -530,7 +533,10 @@ pub trait Backend: Debug + Send + Sync { } fn which(&self, tv: &ToolVersion, bin_name: &str) -> eyre::Result> { - let bin_paths = self.list_bin_paths(tv)?; + let bin_paths = self + .list_bin_paths(tv)? + .into_iter() + .filter(|p| p.parent().is_some()); for bin_path in bin_paths { let bin_path = bin_path.join(bin_name); if bin_path.exists() { diff --git a/src/cli/backends/snapshots/mise__cli__backends__ls__tests__backends_list.snap b/src/cli/backends/snapshots/mise__cli__backends__ls__tests__backends_list.snap index 83a11e44c2..16c4c0d65d 100644 --- a/src/cli/backends/snapshots/mise__cli__backends__ls__tests__backends_list.snap +++ b/src/cli/backends/snapshots/mise__cli__backends__ls__tests__backends_list.snap @@ -3,6 +3,7 @@ source: src/cli/backends/ls.rs expression: output snapshot_kind: text --- +aqua cargo core go diff --git a/src/cli/settings/ls.rs b/src/cli/settings/ls.rs index 74dd7c2d88..4ac8c01be5 100644 --- a/src/cli/settings/ls.rs +++ b/src/cli/settings/ls.rs @@ -63,6 +63,7 @@ mod tests { all_compile = false always_keep_download = true always_keep_install = true + aqua_registry_url = "https://github.com/aquaproj/aqua-registry" asdf_compat = false cache_prune_age = "0" color = true @@ -141,6 +142,7 @@ mod tests { all_compile always_keep_download always_keep_install + aqua_registry_url asdf_compat cache_prune_age cargo diff --git a/src/cli/settings/set.rs b/src/cli/settings/set.rs index 4dfd77f52a..833555b19a 100644 --- a/src/cli/settings/set.rs +++ b/src/cli/settings/set.rs @@ -129,6 +129,7 @@ pub mod tests { all_compile = false always_keep_download = true always_keep_install = true + aqua_registry_url = "https://github.com/aquaproj/aqua-registry" asdf_compat = false cache_prune_age = "0" color = true diff --git a/src/cli/settings/unset.rs b/src/cli/settings/unset.rs index a33b4c545a..456504ef7f 100644 --- a/src/cli/settings/unset.rs +++ b/src/cli/settings/unset.rs @@ -54,6 +54,7 @@ mod tests { all_compile = false always_keep_download = true always_keep_install = true + aqua_registry_url = "https://github.com/aquaproj/aqua-registry" asdf_compat = false cache_prune_age = "0" color = true diff --git a/src/file.rs b/src/file.rs index 25e24f0698..44a3787618 100644 --- a/src/file.rs +++ b/src/file.rs @@ -501,7 +501,8 @@ fn _which>(name: P, paths: &[PathBuf]) -> Option { }) } -pub fn untar(archive: &Path, dest: &Path) -> Result<()> { +pub fn untar_gz(archive: &Path, dest: &Path) -> Result<()> { + // TODO: show progress debug!("tar -xzf {} -C {}", archive.display(), dest.display()); let f = File::open(archive)?; let tar = GzDecoder::new(f); @@ -512,7 +513,21 @@ pub fn untar(archive: &Path, dest: &Path) -> Result<()> { }) } +pub fn untar_xz(archive: &Path, dest: &Path) -> Result<()> { + // TODO: show progress + debug!("tar -xf {} -C {}", archive.display(), dest.display()); + let f = File::open(archive)?; + let tar = xz2::read::XzDecoder::new(f); + Archive::new(tar).unpack(dest).wrap_err_with(|| { + let archive = display_path(archive); + let dest = display_path(dest); + format!("failed to extract tar: {archive} to {dest}") + }) +} + pub fn unzip(archive: &Path, dest: &Path) -> Result<()> { + // TODO: show progress + debug!("unzip {} -d {}", archive.display(), dest.display()); ZipArchive::new(File::open(archive)?) .wrap_err_with(|| format!("failed to open zip archive: {}", display_path(archive)))? .extract(dest) diff --git a/src/github.rs b/src/github.rs index 07b84f12e3..f2f5ef01c7 100644 --- a/src/github.rs +++ b/src/github.rs @@ -11,6 +11,14 @@ pub struct GithubRelease { // pub prerelease: bool, // pub created_at: String, // pub published_at: Option, + pub assets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct GithubAsset { + pub name: String, + // pub size: u64, + pub browser_download_url: String, } pub fn list_releases(repo: &str) -> eyre::Result> { diff --git a/src/main.rs b/src/main.rs index 76b4aa0d06..5d79ecda62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod hint; #[macro_use] mod cmd; +mod aqua; mod backend; pub(crate) mod build_time; mod cache; @@ -43,6 +44,7 @@ mod install_context; mod lock_file; mod lockfile; pub(crate) mod logger; +pub(crate) mod maplit; mod migrate; mod path_env; mod plugins; diff --git a/src/maplit.rs b/src/maplit.rs new file mode 100644 index 0000000000..1321ea5f25 --- /dev/null +++ b/src/maplit.rs @@ -0,0 +1,17 @@ +#[macro_export] +macro_rules! hashmap { + (@single $($x:tt)*) => (()); + (@count $($rest:expr),*) => (<[()]>::len(&[$(hashmap!(@single $rest)),*])); + + ($($key:expr => $value:expr,)+) => { hashmap!($($key => $value),+) }; + ($($key:expr => $value:expr),*) => { + { + let _cap = hashmap!(@count $($key),*); + let mut _map = ::std::collections::HashMap::with_capacity(_cap); + $( + let _ = _map.insert($key, $value); + )* + _map + } + }; +} diff --git a/src/plugins/core/go.rs b/src/plugins/core/go.rs index a413736170..610dfeda5c 100644 --- a/src/plugins/core/go.rs +++ b/src/plugins/core/go.rs @@ -129,7 +129,7 @@ impl GoPlugin { if cfg!(windows) { file::unzip(tarball_path, tmp_extract_path.path())?; } else { - file::untar(tarball_path, tmp_extract_path.path())?; + file::untar_gz(tarball_path, tmp_extract_path.path())?; } file::remove_all(tv.install_path())?; file::rename(tmp_extract_path.path().join("go"), tv.install_path())?; diff --git a/src/plugins/core/java.rs b/src/plugins/core/java.rs index b16f33333d..c36032444e 100644 --- a/src/plugins/core/java.rs +++ b/src/plugins/core/java.rs @@ -121,7 +121,7 @@ impl JavaPlugin { { file::unzip(tarball_path, &tv.download_path())?; } else { - file::untar(tarball_path, &tv.download_path())?; + file::untar_gz(tarball_path, &tv.download_path())?; } self.move_to_install_path(tv, m) } diff --git a/src/plugins/core/node.rs b/src/plugins/core/node.rs index a8a977b618..9fd5e7e02e 100644 --- a/src/plugins/core/node.rs +++ b/src/plugins/core/node.rs @@ -52,7 +52,7 @@ impl NodePlugin { let tarball_name = &opts.binary_tarball_name; ctx.pr.set_message(format!("extracting {tarball_name}")); let tmp_extract_path = tempdir_in(opts.install_path.parent().unwrap())?; - file::untar(&opts.binary_tarball_path, tmp_extract_path.path())?; + file::untar_gz(&opts.binary_tarball_path, tmp_extract_path.path())?; file::remove_all(&opts.install_path)?; file::rename( tmp_extract_path.path().join(slug(&opts.version)), @@ -95,7 +95,7 @@ impl NodePlugin { )?; ctx.pr.set_message(format!("extracting {tarball_name}")); file::remove_all(&opts.build_dir)?; - file::untar(&opts.source_tarball_path, opts.build_dir.parent().unwrap())?; + file::untar_gz(&opts.source_tarball_path, opts.build_dir.parent().unwrap())?; self.exec_configure(ctx, opts)?; self.exec_make(ctx, opts)?; self.exec_make_install(ctx, opts)?; diff --git a/src/plugins/core/python.rs b/src/plugins/core/python.rs index 8dd8cb0b95..af7778370c 100644 --- a/src/plugins/core/python.rs +++ b/src/plugins/core/python.rs @@ -184,7 +184,7 @@ impl PythonPlugin { HTTP.download_file(&url, &tarball_path, Some(ctx.pr.as_ref()))?; ctx.pr.set_message(format!("installing {filename}")); - file::untar(&tarball_path, &download)?; + file::untar_gz(&tarball_path, &download)?; file::remove_all(&install)?; file::rename(download.join("python"), &install)?; #[cfg(unix)] diff --git a/src/registry.rs b/src/registry.rs index 7043abc8d9..fc30fdbdbe 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -13,10 +13,11 @@ include!(concat!(env!("OUT_DIR"), "/registry.rs")); // a rust representation of registry.toml pub static REGISTRY: Lazy>> = Lazy::new(|| { let backend_types = vec![ - "core", "ubi", "vfox", "asdf", "cargo", "go", "npm", "pipx", "spm", + "core", "ubi", "vfox", "asdf", "aqua", "cargo", "go", "npm", "pipx", "spm", ] .into_iter() - .filter(|b| cfg!(unix) || *b != "asdf") + .filter(|b| !(*b == "asdf" && cfg!(windows))) + .filter(|b| !(*b == "aqua" && cfg!(unix) && !SETTINGS.experimental)) .filter(|b| !SETTINGS.disable_backends.contains(&b.to_string())) .collect::>(); diff --git a/src/shims.rs b/src/shims.rs index c94f711510..d340270588 100644 --- a/src/shims.rs +++ b/src/shims.rs @@ -238,6 +238,7 @@ fn list_tool_bins(t: Arc, tv: &ToolVersion) -> Result> Ok(t.list_bin_paths(tv)? .into_iter() .par_bridge() + .filter(|p| p.parent().is_some()) .filter(|path| path.exists()) .map(|dir| list_executables_in_dir(&dir)) .collect::>>()? diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index e6c04962c5..bcae6696cd 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -493,6 +493,7 @@ impl Toolset { Vec::new() }) }) + .filter(|p| p.parent().is_some()) .collect() } pub fn which(&self, bin_name: &str) -> Option<(Arc, ToolVersion)> {