1#![warn(missing_docs)]
63
64use std::{
65 borrow::Cow,
66 env,
67 io::{self, stderr, BufRead, StdinLock},
68 num::NonZeroU64,
69 path::Path,
70 sync::Arc,
71 thread,
72 time::Duration,
73};
74
75use binstalk::{
76 fetchers::{Data, Fetcher, GhCrateMeta, TargetData},
77 get_desired_targets,
78 helpers::{
79 download::ExtractedFilesEntry,
80 gh_api_client::GhApiClient,
81 remote::{Client, Url},
82 },
83 manifests::cargo_toml_binstall::PkgMeta,
84};
85use color_eyre::{
86 eyre::{eyre, Context},
87 Result,
88};
89use crossterm::{
90 cursor::{RestorePosition, SavePosition},
91 style::{Print, ResetColor, Stylize},
92 ExecutableCommand,
93};
94use derive_builder::Builder;
95use serde::Deserialize;
96use tokio::sync::oneshot;
97
98pub fn builder() -> BinswapGithubBuilder {
100 Default::default()
101}
102
103#[derive(Debug, Clone, Builder)]
105pub struct BinswapGithub {
106 #[builder(setter(into))]
108 repo_author: String,
109 #[builder(setter(into))]
111 repo_name: String,
112 #[builder(setter(into, strip_option), default)]
115 asset_name: Option<String>,
116 #[builder(setter(into))]
118 bin_name: String,
119 #[builder(setter(into, strip_option), default)]
121 version: Option<String>,
122 #[builder(setter(into), default = "false")]
124 no_confirm: bool,
125 #[builder(setter(into), default = "\"--help\".to_string()")]
128 check_with_cmd: String,
129 #[builder(setter(into), default = "false")]
131 no_check_with_cmd: bool,
132 #[builder(setter(into), default = "false")]
134 dry_run: bool,
135 #[builder(setter(into, strip_option), default)]
138 targets: Option<Vec<String>>,
139}
140
141impl BinswapGithubBuilder {
142 pub fn add_target(&mut self, target: impl Into<String>) -> &mut Self {
145 self.targets
146 .get_or_insert_with(|| Some(vec![]))
147 .as_mut()
148 .unwrap()
149 .push(target.into());
150 self
151 }
152}
153
154impl BinswapGithub {
155 pub async fn fetch_and_write_in_place_of_current_exec(&self) -> Result<()> {
162 self.fetch_and_write_to(std::env::current_exe()?).await
163 }
164 pub async fn fetch_and_write_to(&self, target_binary: impl AsRef<Path>) -> Result<()> {
166 let target_binary = target_binary.as_ref();
167
168 let name = target_binary
169 .file_name()
170 .ok_or_else(|| eyre!("target file had no name"))?
171 .to_str()
172 .unwrap();
173
174 let temp = tempfile::Builder::new().prefix("binswap").tempdir()?;
175
176 let client = Client::new(
177 concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")),
178 None,
179 Duration::from_millis(5),
180 NonZeroU64::new(1).unwrap(),
181 None,
182 )?;
183
184 let gh_api_client = GhApiClient::new(
185 client.clone(),
186 env::var("GH_TOKEN")
187 .or_else(|_| env::var("GITHUB_TOKEN"))
188 .ok()
189 .map(Into::into),
190 );
191
192 stderr()
193 .execute(Print("Updating ".green()))?
194 .execute(Print(&name))?
195 .execute(Print("...\n".green()))?
196 .execute(ResetColor)?;
197
198 let version = if let Some(v) = self.version.clone() {
199 v
200 } else {
201 #[derive(Debug, Deserialize)]
202 struct Response {
203 tag_name: String,
204 }
205
206 stderr()
207 .execute(Print(
208 "Getting latest version number...\n".magenta().italic(),
209 ))?
210 .execute(ResetColor)?;
211
212 let res: Response = client
213 .get(Url::parse(&format!(
214 "https://api.github.com/repos/{}/{}/releases/latest",
215 self.repo_author, self.repo_name
216 ))?)
217 .send(true)
218 .await?
219 .json()
220 .await?;
221 res.tag_name.trim_start_matches('v').to_string()
222 };
223
224 stderr()
225 .execute(Print("Using version ".green()))?
226 .execute(Print(&version))?
227 .execute(Print("\n"))?
228 .execute(ResetColor)?;
229
230 let targets = if let Some(targets) = self.targets.clone() {
231 targets
232 } else {
233 get_desired_targets(None).get().await.to_vec()
234 };
235 let data = Arc::new(Data::new(
236 self.asset_name
237 .as_deref()
238 .map(Into::into)
239 .unwrap_or_else(|| self.bin_name.as_str().into()),
240 version.into(),
241 Some(format!(
242 "https://github.com/{}/{}/",
243 self.repo_author, self.repo_name
244 )),
245 ));
246 for target in &targets {
247 let resolver = GhCrateMeta::new(
248 client.clone(),
249 gh_api_client.clone(),
250 data.clone(),
251 Arc::new(TargetData {
252 target: target.into(),
253 meta: PkgMeta::default(),
254 }),
255 );
256
257 stderr()
258 .execute(Print("Looking for binary for target ".magenta().italic()))?
259 .execute(Print(&target))?
260 .execute(Print("...\n".magenta().italic()))?;
261
262 let found = Arc::clone(&resolver).find().await??;
263 if !found {
264 continue;
265 }
266
267 stderr().execute(Print("Found a binary! Downloading...\n".magenta().italic()))?;
268
269 let extracted_files = resolver.fetch_and_extract(temp.path()).await?;
270
271 let bin_name = Path::new(&self.bin_name);
272
273 let bin_name = if target.contains("windows") {
274 Cow::Owned(bin_name.with_extension("exe"))
275 } else {
276 Cow::Borrowed(bin_name)
277 };
278
279 let bin_path = if extracted_files.has_file(&bin_name) {
280 temp.path().join(&bin_name)
281 } else {
282 let res = (|| {
283 let entries = extracted_files.get_dir(Path::new("."))?;
284 for entry in entries {
285 if let Some(ExtractedFilesEntry::Dir(entries)) =
286 extracted_files.get_entry(Path::new(entry))
287 {
288 if entries.contains(bin_name.as_os_str()) {
289 let mut p = temp.path().join(Path::new(&**entry));
290 p.push(&bin_name);
291 return Some(p);
292 }
293 }
294 }
295
296 None
297 })();
298
299 if let Some(bin_path) = res {
300 bin_path
301 } else {
302 stderr().execute(Print(
303 " > No binary found in asset, trying next target...\n"
304 .red()
305 .italic(),
306 ))?;
307 continue;
308 }
309 };
310
311 if !self.no_check_with_cmd {
312 let res = tokio::process::Command::new(&bin_path)
313 .arg(&self.check_with_cmd)
314 .output()
315 .await?;
316 if !res.status.success() {
317 return Err(eyre!(
318 "Could not execute `{}` on downloaded binary: {}",
319 self.check_with_cmd,
320 res.status,
321 ));
322 }
323 }
324
325 stderr()
326 .execute(Print("\n About to write binary to ".green()))?
327 .execute(Print(format!("`{}`\n", target_binary.display())))?;
328
329 if self.no_confirm || confirm().await {
330 if !self.dry_run {
331 let backup_bin = temp.path().join("backup-binary");
332
333 tokio::fs::rename(target_binary, &backup_bin)
341 .await
342 .wrap_err("failed to move old binary before updating to new")?;
343 if let Err(e) = tokio::fs::rename(bin_path, target_binary).await {
344 if let Err(e2) = tokio::fs::rename(backup_bin, target_binary).await {
345 let error_msg = "failed to move old binary back after failing to move new binary into target destination";
346 return Err(e2).wrap_err(error_msg).wrap_err(e);
347 } else {
348 return Err(e)
349 .wrap_err("failed to put new binary into target destination");
350 }
351 }
352 }
353
354 stderr()
355 .execute(Print("\n".green()))?
356 .execute(Print(&name))?
357 .execute(Print(" has been updated!".green()))?
358 .execute(Print(
359 if self.dry_run {
360 " (not actually since it was a dry-run)"
361 } else {
362 ""
363 }
364 .dim(),
365 ))?
366 .execute(Print("\n"))?
367 .execute(ResetColor)?;
368 } else {
369 return Ok(());
370 }
371
372 return Ok(());
373 }
374
375 drop(temp);
376
377 Err(eyre!("not found"))
378 }
379}
380
381fn ask_for_confirm(stdin: &mut StdinLock, input: &mut String) -> io::Result<()> {
382 stderr()
383 .execute(Print("\n Do you wish to continue? ".yellow()))?
384 .execute(Print("yes/[no]\n"))?
385 .execute(Print(" ? ".dim()))?
386 .execute(SavePosition)?
387 .execute(Print("\n"))?
388 .execute(RestorePosition)?;
389
390 stdin.read_line(input)?;
391
392 Ok(())
393}
394
395async fn confirm() -> bool {
396 let (tx, rx) = oneshot::channel();
397
398 thread::spawn(move || {
399 let mut stdin = io::stdin().lock();
402 let mut input = String::with_capacity(16);
403
404 let res = loop {
405 if ask_for_confirm(&mut stdin, &mut input).is_err() {
406 break false;
407 }
408
409 match input.as_str().trim() {
410 "yes" | "y" | "YES" | "Y" => break true,
411 "no" | "n" | "NO" | "N" | "" => break false,
412 _ => {
413 input.clear();
414 continue;
415 }
416 }
417 };
418
419 tx.send(res).ok();
422 });
423
424 rx.await.unwrap()
425}