#![warn(missing_docs)]
use std::{
borrow::Cow,
env,
io::{self, stderr, BufRead, StdinLock},
num::NonZeroU64,
path::Path,
sync::Arc,
thread,
time::Duration,
};
use binstalk::{
fetchers::{Data, Fetcher, GhCrateMeta, TargetData},
get_desired_targets,
helpers::{
download::ExtractedFilesEntry,
gh_api_client::GhApiClient,
remote::{Client, Url},
},
manifests::cargo_toml_binstall::PkgMeta,
};
use color_eyre::{
eyre::{eyre, Context},
Result,
};
use crossterm::{
cursor::{RestorePosition, SavePosition},
style::{Print, ResetColor, Stylize},
ExecutableCommand,
};
use derive_builder::Builder;
use serde::Deserialize;
use tokio::sync::oneshot;
pub fn builder() -> BinswapGithubBuilder {
Default::default()
}
#[derive(Debug, Clone, Builder)]
pub struct BinswapGithub {
#[builder(setter(into))]
repo_author: String,
#[builder(setter(into))]
repo_name: String,
#[builder(setter(into, strip_option), default)]
asset_name: Option<String>,
#[builder(setter(into))]
bin_name: String,
#[builder(setter(into, strip_option), default)]
version: Option<String>,
#[builder(setter(into), default = "false")]
no_confirm: bool,
#[builder(setter(into), default = "\"--help\".to_string()")]
check_with_cmd: String,
#[builder(setter(into), default = "false")]
no_check_with_cmd: bool,
#[builder(setter(into), default = "false")]
dry_run: bool,
#[builder(setter(into, strip_option), default)]
targets: Option<Vec<String>>,
}
impl BinswapGithubBuilder {
pub fn add_target(&mut self, target: impl Into<String>) -> &mut Self {
self.targets
.get_or_insert_with(|| Some(vec![]))
.as_mut()
.unwrap()
.push(target.into());
self
}
}
impl BinswapGithub {
pub async fn fetch_and_write_in_place_of_current_exec(&self) -> Result<()> {
self.fetch_and_write_to(std::env::current_exe()?).await
}
pub async fn fetch_and_write_to(&self, target_binary: impl AsRef<Path>) -> Result<()> {
let target_binary = target_binary.as_ref();
let name = target_binary
.file_name()
.ok_or_else(|| eyre!("target file had no name"))?
.to_str()
.unwrap();
let temp = tempfile::Builder::new().prefix("binswap").tempdir()?;
let client = Client::new(
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")),
None,
Duration::from_millis(5),
NonZeroU64::new(1).unwrap(),
None,
)?;
let gh_api_client = GhApiClient::new(
client.clone(),
env::var("GH_TOKEN")
.or_else(|_| env::var("GITHUB_TOKEN"))
.ok()
.map(Into::into),
);
stderr()
.execute(Print("Updating ".green()))?
.execute(Print(&name))?
.execute(Print("...\n".green()))?
.execute(ResetColor)?;
let version = if let Some(v) = self.version.clone() {
v
} else {
#[derive(Debug, Deserialize)]
struct Response {
tag_name: String,
}
stderr()
.execute(Print(
"Getting latest version number...\n".magenta().italic(),
))?
.execute(ResetColor)?;
let res: Response = client
.get(Url::parse(&format!(
"https://api.github.com/repos/{}/{}/releases/latest",
self.repo_author, self.repo_name
))?)
.send(true)
.await?
.json()
.await?;
res.tag_name.trim_start_matches('v').to_string()
};
stderr()
.execute(Print("Using version ".green()))?
.execute(Print(&version))?
.execute(Print("\n"))?
.execute(ResetColor)?;
let targets = if let Some(targets) = self.targets.clone() {
targets
} else {
get_desired_targets(None).get().await.to_vec()
};
let data = Arc::new(Data::new(
self.asset_name
.as_deref()
.map(Into::into)
.unwrap_or_else(|| self.bin_name.as_str().into()),
version.into(),
Some(format!(
"https://github.com/{}/{}/",
self.repo_author, self.repo_name
)),
));
for target in &targets {
let resolver = GhCrateMeta::new(
client.clone(),
gh_api_client.clone(),
data.clone(),
Arc::new(TargetData {
target: target.into(),
meta: PkgMeta::default(),
}),
);
stderr()
.execute(Print("Looking for binary for target ".magenta().italic()))?
.execute(Print(&target))?
.execute(Print("...\n".magenta().italic()))?;
let found = Arc::clone(&resolver).find().await??;
if !found {
continue;
}
stderr().execute(Print("Found a binary! Downloading...\n".magenta().italic()))?;
let extracted_files = resolver.fetch_and_extract(temp.path()).await?;
let bin_name = Path::new(&self.bin_name);
let bin_name = if target.contains("windows") {
Cow::Owned(bin_name.with_extension("exe"))
} else {
Cow::Borrowed(bin_name)
};
let bin_path = if extracted_files.has_file(&bin_name) {
temp.path().join(&bin_name)
} else {
let res = (|| {
let entries = extracted_files.get_dir(Path::new("."))?;
for entry in entries {
if let Some(ExtractedFilesEntry::Dir(entries)) =
extracted_files.get_entry(Path::new(entry))
{
if entries.contains(bin_name.as_os_str()) {
let mut p = temp.path().join(Path::new(&**entry));
p.push(&bin_name);
return Some(p);
}
}
}
None
})();
if let Some(bin_path) = res {
bin_path
} else {
stderr().execute(Print(
" > No binary found in asset, trying next target...\n"
.red()
.italic(),
))?;
continue;
}
};
if !self.no_check_with_cmd {
let res = tokio::process::Command::new(&bin_path)
.arg(&self.check_with_cmd)
.output()
.await?;
if !res.status.success() {
return Err(eyre!(
"Could not execute `{}` on downloaded binary: {}",
self.check_with_cmd,
res.status,
));
}
}
stderr()
.execute(Print("\n About to write binary to ".green()))?
.execute(Print(format!("`{}`\n", target_binary.display())))?;
if self.no_confirm || confirm().await {
if !self.dry_run {
let backup_bin = temp.path().join("backup-binary");
tokio::fs::rename(target_binary, &backup_bin)
.await
.wrap_err("failed to move old binary before updating to new")?;
if let Err(e) = tokio::fs::rename(bin_path, target_binary).await {
if let Err(e2) = tokio::fs::rename(backup_bin, target_binary).await {
let error_msg = "failed to move old binary back after failing to move new binary into target destination";
return Err(e2).wrap_err(error_msg).wrap_err(e);
} else {
return Err(e)
.wrap_err("failed to put new binary into target destination");
}
}
}
stderr()
.execute(Print("\n".green()))?
.execute(Print(&name))?
.execute(Print(" has been updated!".green()))?
.execute(Print(
if self.dry_run {
" (not actually since it was a dry-run)"
} else {
""
}
.dim(),
))?
.execute(Print("\n"))?
.execute(ResetColor)?;
} else {
return Ok(());
}
return Ok(());
}
drop(temp);
Err(eyre!("not found"))
}
}
fn ask_for_confirm(stdin: &mut StdinLock, input: &mut String) -> io::Result<()> {
stderr()
.execute(Print("\n Do you wish to continue? ".yellow()))?
.execute(Print("yes/[no]\n"))?
.execute(Print(" ? ".dim()))?
.execute(SavePosition)?
.execute(Print("\n"))?
.execute(RestorePosition)?;
stdin.read_line(input)?;
Ok(())
}
async fn confirm() -> bool {
let (tx, rx) = oneshot::channel();
thread::spawn(move || {
let mut stdin = io::stdin().lock();
let mut input = String::with_capacity(16);
let res = loop {
if ask_for_confirm(&mut stdin, &mut input).is_err() {
break false;
}
match input.as_str().trim() {
"yes" | "y" | "YES" | "Y" => break true,
"no" | "n" | "NO" | "N" | "" => break false,
_ => {
input.clear();
continue;
}
}
};
tx.send(res).ok();
});
rx.await.unwrap()
}