|
| 1 | +use std::fs; |
| 2 | +use std::path::PathBuf; |
| 3 | + |
| 4 | +use anyhow::{bail, Result}; |
| 5 | +use clap::{Arg, ArgMatches, Command}; |
| 6 | +use log::info; |
| 7 | + |
| 8 | +use crate::api::Api; |
| 9 | +use crate::config::Config; |
| 10 | +use crate::utils::args::ArgExt as _; |
| 11 | +use crate::utils::fs::TempFile; |
| 12 | + |
| 13 | +const EXPERIMENTAL_WARNING: &str = |
| 14 | + "[EXPERIMENTAL] The \"build download\" command is experimental. \ |
| 15 | + The command is subject to breaking changes, including removal, in any Sentry CLI release."; |
| 16 | + |
| 17 | +pub fn make_command(command: Command) -> Command { |
| 18 | + command |
| 19 | + .about("[EXPERIMENTAL] Download a build artifact.") |
| 20 | + .long_about(format!( |
| 21 | + "Download a build artifact.\n\n{EXPERIMENTAL_WARNING}" |
| 22 | + )) |
| 23 | + .org_arg() |
| 24 | + .arg( |
| 25 | + Arg::new("build_id") |
| 26 | + .long("build-id") |
| 27 | + .short('b') |
| 28 | + .required(true) |
| 29 | + .help("The ID of the build to download."), |
| 30 | + ) |
| 31 | + .arg(Arg::new("output").long("output").help( |
| 32 | + "The output file path. Defaults to \ |
| 33 | + 'preprod_artifact_<build_id>.<ext>' in the current directory, \ |
| 34 | + where ext is ipa or apk depending on the platform.", |
| 35 | + )) |
| 36 | +} |
| 37 | + |
| 38 | +/// For iOS builds, the install URL points to a plist manifest. |
| 39 | +/// Replace the response_format to download the actual IPA binary instead. |
| 40 | +fn ensure_binary_format(url: &str) -> String { |
| 41 | + url.replace("response_format=plist", "response_format=ipa") |
| 42 | +} |
| 43 | + |
| 44 | +/// Extract the file extension from the response_format query parameter. |
| 45 | +fn extension_from_url(url: &str) -> Result<&str> { |
| 46 | + if url.contains("response_format=ipa") { |
| 47 | + Ok("ipa") |
| 48 | + } else if url.contains("response_format=apk") { |
| 49 | + Ok("apk") |
| 50 | + } else { |
| 51 | + bail!("Unsupported build format in download URL.") |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +pub fn execute(matches: &ArgMatches) -> Result<()> { |
| 56 | + eprintln!("{EXPERIMENTAL_WARNING}"); |
| 57 | + let config = Config::current(); |
| 58 | + let org = config.get_org(matches)?; |
| 59 | + let build_id = matches |
| 60 | + .get_one::<String>("build_id") |
| 61 | + .expect("build_id is required"); |
| 62 | + |
| 63 | + let api = Api::current(); |
| 64 | + let authenticated_api = api.authenticated()?; |
| 65 | + |
| 66 | + info!("Fetching install details for build {build_id}"); |
| 67 | + let details = authenticated_api.get_build_install_details(&org, build_id)?; |
| 68 | + |
| 69 | + if !details.is_installable { |
| 70 | + bail!("Build {build_id} is not installable."); |
| 71 | + } |
| 72 | + |
| 73 | + let install_url = details |
| 74 | + .install_url |
| 75 | + .ok_or_else(|| anyhow::anyhow!("Build {build_id} has no install URL."))?; |
| 76 | + |
| 77 | + let download_url = ensure_binary_format(&install_url); |
| 78 | + |
| 79 | + let output_path = match matches.get_one::<String>("output") { |
| 80 | + Some(path) => PathBuf::from(path), |
| 81 | + None => { |
| 82 | + let ext = extension_from_url(&download_url)?; |
| 83 | + PathBuf::from(format!("preprod_artifact_{build_id}.{ext}")) |
| 84 | + } |
| 85 | + }; |
| 86 | + |
| 87 | + info!("Downloading build {build_id} to {}", output_path.display()); |
| 88 | + |
| 89 | + let tmp = TempFile::create()?; |
| 90 | + let mut file = tmp.open()?; |
| 91 | + let response = authenticated_api.download_installable_build(&download_url, &mut file)?; |
| 92 | + |
| 93 | + if response.failed() { |
| 94 | + bail!( |
| 95 | + "Failed to download build (server returned status {}).", |
| 96 | + response.status() |
| 97 | + ); |
| 98 | + } |
| 99 | + |
| 100 | + drop(file); |
| 101 | + fs::copy(tmp.path(), &output_path)?; |
| 102 | + |
| 103 | + println!("Successfully downloaded build to {}", output_path.display()); |
| 104 | + |
| 105 | + Ok(()) |
| 106 | +} |
0 commit comments