diff --git a/Cargo.lock b/Cargo.lock index f26c3e60..373832ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,7 @@ dependencies = [ "flate2", "log", "reqwest", + "semver", "serde", "serde_derive", "simplelog", diff --git a/Cargo.toml b/Cargo.toml index 6c1f27db..efa4e1de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,5 @@ strum_macros = "0.20.1" strum = "0.20.0" dirs = "3.0.1" serde_derive = "1.0.118" +#github = "0.1.2" +semver = "0.11.0" diff --git a/README.md b/README.md index ae39872f..f78a4f7e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Cargo metadata is used to avoid the need for an additional centralised index or First you'll need to install `cargo-binstall` either via `cargo install cargo-binstall` (and it'll have to compile, sorry...), or by grabbing a pre-compiled version from the [releases](https://github.com/ryankurte/cargo-binstall/releases) page and putting that somewhere on your path. It's like there's a problem we're trying to solve? Once a project supports `binstall` you can then install binaries via `cargo binstall NAME` where `NAME` is the name of the crate. This will then fetch the metadata for the provided crate, lookup the associated binary file, and download this onto your system. -By default the latest version is installed, which can be overridden using the `--version` argument, and packages are installed to `$HOME/.cargo/bin` as is consistent with `cargo install`, which can be overridden via the `--install-path` argument. As always `--help` will show available options. +By default the latest version from is installed, which can be overridden using the `--version` argument, and packages are installed to `$HOME/.cargo/bin` as is consistent with `cargo install`, which can be overridden via the `--install-path` argument. As always `--help` will show available options. We hope the defaults will work without configuration in _some_ cases, however, different projects have wildly different CI and build output configurations. You will likely need to add some cargo metadata to support `binstall` in your project, see [Supporting Binary Installation](#Supporting-Binary-Installation) for details. diff --git a/src/drivers.rs b/src/drivers.rs new file mode 100644 index 00000000..41349fc8 --- /dev/null +++ b/src/drivers.rs @@ -0,0 +1,80 @@ + +use std::time::Duration; +use std::path::{Path, PathBuf}; + +use log::{debug, error}; + +use crates_io_api::AsyncClient; +use semver::Version; + +use crate::PkgFmt; +use crate::helpers::*; + +/// Fetch a crate by name and version from crates.io +pub async fn fetch_crate_cratesio(name: &str, version: Option<&str>, temp_dir: &Path) -> Result { + // Build crates.io api client and fetch info + // TODO: support git-based fetches (whole repo name rather than just crate name) + let api_client = AsyncClient::new("cargo-binstall (https://github.com/ryankurte/cargo-binstall)", Duration::from_millis(100))?; + + debug!("Fetching information for crate: '{}'", name); + + // Fetch overall crate info + let info = match api_client.get_crate(name.as_ref()).await { + Ok(i) => i, + Err(e) => { + error!("Error fetching information for crate {}: {}", name, e); + return Err(e.into()) + } + }; + + // Use specified or latest version + let version_num = match version { + Some(v) => v.to_string(), + None => info.crate_data.max_version, + }; + + // Fetch crates.io information for the specified version + // TODO: Filter by semver matches instead of literal match + let mut versions = info.versions.clone(); + versions.sort_by(|a, b| { + let ver_a = Version::parse(&a.num).unwrap(); + let ver_b = Version::parse(&b.num).unwrap(); + + ver_a.partial_cmp(&ver_b).unwrap() + } ); + + let version = match versions.iter().find(|v| v.num == version_num) { + Some(v) => v, + None => { + error!("No crates.io information found for crate: '{}' version: '{}'", + name, version_num); + return Err(anyhow::anyhow!("No crate information found")); + } + }; + + debug!("Found information for crate version: '{}'", version.num); + + // Download crate to temporary dir (crates.io or git?) + let crate_url = format!("https://crates.io/{}", version.dl_path); + let tgz_path = temp_dir.join(format!("{}.tgz", name)); + + debug!("Fetching crate from: {}", crate_url); + + // Download crate + download(&crate_url, &tgz_path).await?; + + // Decompress downloaded tgz + debug!("Decompressing crate archive"); + extract(&tgz_path, PkgFmt::Tgz, &temp_dir)?; + let crate_path = temp_dir.join(format!("{}-{}", name, version_num)); + + // Return crate directory + Ok(crate_path) +} + +/// Fetch a crate by name and version from github +/// TODO: implement this +pub async fn fetch_crate_gh_releases(_name: &str, _version: Option<&str>, _temp_dir: &Path) -> Result { + + unimplemented!(); +} \ No newline at end of file diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 00000000..6d419858 --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,65 @@ + +use std::path::Path; + +use log::{debug, error}; + +use flate2::read::GzDecoder; +use tar::Archive; + + +use super::PkgFmt; + + +/// Download a file from the provided URL to the provided path +pub async fn download>(url: &str, path: P) -> Result<(), anyhow::Error> { + + debug!("Downloading from: '{}'", url); + + let resp = reqwest::get(url).await?; + + if !resp.status().is_success() { + error!("Download error: {}", resp.status()); + return Err(anyhow::anyhow!(resp.status())); + } + + let bytes = resp.bytes().await?; + + debug!("Download OK, writing to file: '{:?}'", path.as_ref()); + + std::fs::write(&path, bytes)?; + + Ok(()) +} + +/// Extract files from the specified source onto the specified path +pub fn extract, P: AsRef>(source: S, fmt: PkgFmt, path: P) -> Result<(), anyhow::Error> { + match fmt { + PkgFmt::Tar => { + // Extract to install dir + debug!("Extracting from archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + + let dat = std::fs::File::open(source)?; + let mut tar = Archive::new(dat); + + tar.unpack(path)?; + }, + PkgFmt::Tgz => { + // Extract to install dir + debug!("Decompressing from archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + + let dat = std::fs::File::open(source)?; + let tar = GzDecoder::new(dat); + let mut tgz = Archive::new(tar); + + tgz.unpack(path)?; + }, + PkgFmt::Bin => { + debug!("Copying data from archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + // Copy to install dir + std::fs::copy(source, path)?; + }, + }; + + Ok(()) +} + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..5e7e2a18 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,75 @@ +use structopt::StructOpt; +use serde::{Serialize, Deserialize}; +use strum_macros::{Display, EnumString, EnumVariantNames}; +use tinytemplate::TinyTemplate; + + +pub mod helpers; +pub use helpers::*; + +pub mod drivers; +pub use drivers::*; + + +/// Compiled target triple, used as default for binary fetching +pub const TARGET: &'static str = env!("TARGET"); + +/// Default package path for use if no path is specified +pub const DEFAULT_PKG_PATH: &'static str = "{ repo }/releases/download/v{ version }/{ name }-{ target }-v{ version }.{ format }"; + + +/// Binary format enumeration +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Display, EnumString, EnumVariantNames)] +#[strum(serialize_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum PkgFmt { + /// Download format is TAR (uncompressed) + Tar, + /// Download format is TGZ (TAR + GZip) + Tgz, + /// Download format is raw / binary + Bin, +} + + +/// Metadata for binary installation use. +/// +/// Exposed via `[package.metadata]` in `Cargo.toml` +#[derive(Clone, Debug, StructOpt, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Meta { + /// Path template override for binary downloads + pub pkg_url: Option, + /// Package name override for binary downloads + pub pkg_name: Option, + /// Format override for binary downloads + pub pkg_fmt: Option, +} + + +/// Template for constructing download paths +#[derive(Clone, Debug, Serialize)] +pub struct Context { + pub name: String, + pub repo: Option, + pub target: String, + pub version: String, + pub format: PkgFmt, +} + +impl Context { + /// Render the context into the provided template + pub fn render(&self, template: &str) -> Result { + // Create template instance + let mut tt = TinyTemplate::new(); + + // Add template to instance + tt.add_template("path", &template)?; + + // Render output + let rendered = tt.render("path", self)?; + + Ok(rendered) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b5ec71af..8ee3cfb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,62 +1,57 @@ -use std::time::Duration; -use std::path::{PathBuf, Path}; +use std::path::{PathBuf}; use log::{debug, info, error, LevelFilter}; use simplelog::{TermLogger, ConfigBuilder, TerminalMode}; use structopt::StructOpt; -use serde::{Serialize, Deserialize}; -use crates_io_api::AsyncClient; use cargo_toml::Manifest; use tempdir::TempDir; -use flate2::read::GzDecoder; -use tar::Archive; -use tinytemplate::TinyTemplate; +use cargo_binstall::*; -/// Compiled target triple, used as default for binary fetching -const TARGET: &'static str = env!("TARGET"); - -/// Default binary path for use if no path is specified -const DEFAULT_BIN_PATH: &'static str = "{ repo }/releases/download/v{ version }/{ name }-{ target }-v{ version }.{ format }"; - -/// Binary format enumeration -#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] -#[derive(strum_macros::Display, strum_macros::EnumString, strum_macros::EnumVariantNames)] -#[strum(serialize_all = "snake_case")] -#[serde(rename_all = "snake_case")] -pub enum PkgFmt { - /// Download format is TAR (uncompressed) - Tar, - /// Download format is TGZ (TAR + GZip) - Tgz, - /// Download format is raw / binary - Bin, -} #[derive(Debug, StructOpt)] struct Options { - /// Crate name to install + /// Package name or URL for installation + /// This must be either a crates.io package name or github or gitlab url #[structopt()] name: String, - /// Crate version to install + /// Package version to instal #[structopt(long)] version: Option, - /// Override the package path template. - /// If no `metadata.pkg_url` key is set or `--pkg-url` argument provided, this - /// defaults to `{ repo }/releases/download/v{ version }/{ name }-{ target }-v{ version }.tgz` - #[structopt(long)] - pkg_url: Option, + /// Override binary target, ignoring compiled version + #[structopt(long, default_value = TARGET)] + target: String, /// Override format for binary file download. /// Defaults to `tgz` #[structopt(long)] pkg_fmt: Option, + /// Override install path for downloaded binary. + /// Defaults to `$HOME/.cargo/bin` + #[structopt(long)] + install_path: Option, + + #[structopt(flatten)] + overrides: Overrides, + + /// Do not cleanup temporary files on success + #[structopt(long)] + no_cleanup: bool, + + /// Utility log level + #[structopt(long, default_value = "info")] + log_level: LevelFilter, +} + +#[derive(Debug, StructOpt)] +pub struct Overrides { + /// Override the package name. /// This is only useful for diagnostics when using the default `pkg_url` /// as you can otherwise customise this in the path. @@ -64,53 +59,20 @@ struct Options { #[structopt(long)] pkg_name: Option, - /// Override install path for downloaded binary. - /// Defaults to `$HOME/.cargo/bin` + /// Override the package path template. + /// If no `metadata.pkg_url` key is set or `--pkg-url` argument provided, this + /// defaults to `{ repo }/releases/download/v{ version }/{ name }-{ target }-v{ version }.tgz` #[structopt(long)] - install_path: Option, - - /// Override binary target, ignoring compiled version - #[structopt(long, default_value = TARGET)] - target: String, + pkg_url: Option, /// Override manifest source. /// This skips searching crates.io for a manifest and uses /// the specified path directly, useful for debugging #[structopt(long)] manifest_path: Option, - - /// Utility log level - #[structopt(long, default_value = "info")] - log_level: LevelFilter, - - /// Do not cleanup temporary files on success - #[structopt(long)] - no_cleanup: bool, } -/// Metadata for cargo-binstall exposed via cargo.toml -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct Meta { - /// Path template override for binary downloads - pub pkg_url: Option, - /// Package name override for binary downloads - pub pkg_name: Option, - /// Format override for binary downloads - pub pkg_fmt: Option, -} - -/// Template for constructing download paths -#[derive(Clone, Debug, Serialize)] -pub struct Context { - name: String, - repo: Option, - target: String, - version: String, - format: PkgFmt, -} - #[tokio::main] async fn main() -> Result<(), anyhow::Error> { @@ -138,7 +100,7 @@ async fn main() -> Result<(), anyhow::Error> { // Fetch crate via crates.io, git, or use a local manifest path // TODO: work out which of these to do based on `opts.name` - let crate_path = match opts.manifest_path { + let crate_path = match opts.overrides.manifest_path { Some(p) => p, None => fetch_crate_cratesio(&opts.name, opts.version.as_deref(), temp_dir.path()).await?, }; @@ -158,8 +120,8 @@ async fn main() -> Result<(), anyhow::Error> { let meta = package.metadata; debug!("Retrieved metadata: {:?}", meta); - // Select which binary path to use - let pkg_url = match (opts.pkg_url, meta.as_ref().map(|m| m.pkg_url.clone() ).flatten()) { + // Select which package path to use + let pkg_url = match (opts.overrides.pkg_url, meta.as_ref().map(|m| m.pkg_url.clone() ).flatten()) { (Some(p), _) => { info!("Using package url override: '{}'", p); p @@ -170,8 +132,8 @@ async fn main() -> Result<(), anyhow::Error> { }, _ => { info!("No `pkg-url` key found in Cargo.toml or `--pkg-url` argument provided"); - info!("Using default url: {}", DEFAULT_BIN_PATH); - DEFAULT_BIN_PATH.to_string() + info!("Using default url: {}", DEFAULT_PKG_PATH); + DEFAULT_PKG_PATH.to_string() }, }; @@ -183,7 +145,7 @@ async fn main() -> Result<(), anyhow::Error> { }; // Override package name if required - let pkg_name = match (&opts.pkg_name, meta.as_ref().map(|m| m.pkg_name.clone() ).flatten()) { + let pkg_name = match (&opts.overrides.pkg_name, meta.as_ref().map(|m| m.pkg_name.clone() ).flatten()) { (Some(o), _) => o.clone(), (_, Some(m)) => m, _ => opts.name.clone(), @@ -201,9 +163,7 @@ async fn main() -> Result<(), anyhow::Error> { debug!("Using context: {:?}", ctx); // Interpolate version / target / etc. - let mut tt = TinyTemplate::new(); - tt.add_template("path", &pkg_url)?; - let rendered = tt.render("path", &ctx)?; + let rendered = ctx.render(&pkg_url)?; info!("Downloading package from: '{}'", rendered); @@ -238,111 +198,7 @@ async fn main() -> Result<(), anyhow::Error> { Ok(()) } -/// Download a file from the provided URL to the provided path -async fn download>(url: &str, path: P) -> Result<(), anyhow::Error> { - debug!("Downloading from: '{}'", url); - - let resp = reqwest::get(url).await?; - - if !resp.status().is_success() { - error!("Download error: {}", resp.status()); - return Err(anyhow::anyhow!(resp.status())); - } - - let bytes = resp.bytes().await?; - - debug!("Download OK, writing to file: '{:?}'", path.as_ref()); - - std::fs::write(&path, bytes)?; - - Ok(()) -} - -fn extract, P: AsRef>(source: S, fmt: PkgFmt, path: P) -> Result<(), anyhow::Error> { - match fmt { - PkgFmt::Tar => { - // Extract to install dir - debug!("Extracting from archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); - - let dat = std::fs::File::open(source)?; - let mut tar = Archive::new(dat); - - tar.unpack(path)?; - }, - PkgFmt::Tgz => { - // Extract to install dir - debug!("Decompressing from archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); - - let dat = std::fs::File::open(source)?; - let tar = GzDecoder::new(dat); - let mut tgz = Archive::new(tar); - - tgz.unpack(path)?; - }, - PkgFmt::Bin => { - debug!("Copying data from archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); - // Copy to install dir - std::fs::copy(source, path)?; - }, - }; - - Ok(()) -} - -/// Fetch a crate by name and version from crates.io -async fn fetch_crate_cratesio(name: &str, version: Option<&str>, temp_dir: &Path) -> Result { - // Build crates.io api client and fetch info - // TODO: support git-based fetches (whole repo name rather than just crate name) - let api_client = AsyncClient::new("cargo-binstall (https://github.com/ryankurte/cargo-binstall)", Duration::from_millis(100))?; - - info!("Fetching information for crate: '{}'", name); - - // Fetch overall crate info - let info = match api_client.get_crate(name.as_ref()).await { - Ok(i) => i, - Err(e) => { - error!("Error fetching information for crate {}: {}", name, e); - return Err(e.into()) - } - }; - - // Use specified or latest version - let version_num = match version { - Some(v) => v.to_string(), - None => info.crate_data.max_version, - }; - - // Fetch crates.io information for the specified version - // TODO: could do a semver match and sort here? - let version = match info.versions.iter().find(|v| v.num == version_num) { - Some(v) => v, - None => { - error!("No crates.io information found for crate: '{}' version: '{}'", - name, version_num); - return Err(anyhow::anyhow!("No crate information found")); - } - }; - - info!("Found information for crate version: '{}'", version.num); - - // Download crate to temporary dir (crates.io or git?) - let crate_url = format!("https://crates.io/{}", version.dl_path); - let tgz_path = temp_dir.join(format!("{}.tgz", name)); - - debug!("Fetching crate from: {}", crate_url); - - // Download crate - download(&crate_url, &tgz_path).await?; - - // Decompress downloaded tgz - debug!("Decompressing crate archive"); - extract(&tgz_path, PkgFmt::Tgz, &temp_dir)?; - let crate_path = temp_dir.join(format!("{}-{}", name, version_num)); - - // Return crate directory - Ok(crate_path) -} /// Fetch install path /// roughly follows https://doc.rust-lang.org/cargo/commands/cargo-install.html#description