use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::builder::PossibleValue;
use clap::{Parser, ValueEnum};
use log::{Level, LevelFilter, info};
use eish::github::{Client, release_from_json_file};
use eish::spec::RepoSpec;
use eish::{InstallSpec, Proxy, Resource, Shell};
#[derive(Debug, Parser)]
#[command(
name = "eish",
version = eish::VERSION,
about = "Generate installation scripts for GitHub release binaries",
long_about = None,
after_help = "Examples:\n \
eish easy-install/easy-install > install.sh\n \
eish cli/cli@v2.40.0 --shell powershell > install.ps1\n \
eish owner/repo --shell fish --proxy xget > install.fish\n \
eish owner/repo@v1.0.0 | bash"
)]
pub struct Cli {
#[arg(value_name = "SPEC")]
spec: String,
#[arg(short, long, value_enum, default_value_t = Shell::Bash)]
shell: Shell,
#[arg(long, value_enum, default_value_t = Proxy::Github)]
proxy: Proxy,
#[arg(long)]
tag: Option<String>,
#[arg(short, long)]
binary: Option<String>,
#[arg(long, value_name = "NAME")]
name: Option<String>,
#[arg(long)]
target: Option<String>,
#[arg(long, default_value = eish::spec::DEFAULT_INSTALL_DIR)]
dir: String,
#[arg(long = "type", value_enum, default_value_t = ResourceType::Release)]
resource_type: ResourceType,
#[arg(long = "ref", default_value = "main")]
reference: String,
#[arg(long, default_value_t = eish::spec::DEFAULT_MIN_DISK_SPACE_MB)]
min_disk_space: u64,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long, default_value = eish::github::DEFAULT_API_BASE)]
api_base: String,
#[arg(long, value_name = "PATH")]
release_json: Option<PathBuf>,
#[arg(long = "asset", value_name = "FILE")]
assets: Vec<String>,
#[arg(long)]
list: bool,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(short, long)]
quiet: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceType {
Release,
File,
}
impl ValueEnum for ResourceType {
fn value_variants<'a>() -> &'a [Self] {
&[ResourceType::Release, ResourceType::File]
}
fn to_possible_value(&self) -> Option<PossibleValue> {
Some(match self {
ResourceType::Release => {
PossibleValue::new("release").help("a file attached to a GitHub release")
}
ResourceType::File => {
PossibleValue::new("file").help("a file committed to the repository (see --ref)")
}
})
}
}
impl Cli {
pub fn init_logging(&self) {
let default = if self.quiet {
LevelFilter::Error
} else {
match self.verbose {
0 => LevelFilter::Warn,
1 => LevelFilter::Info,
_ => LevelFilter::Debug,
}
};
let mut builder = env_logger::Builder::new();
builder.filter_level(default);
builder.parse_env("EISH_LOG");
builder.parse_default_env();
builder.format(|buffer, record| {
use std::io::Write as _;
let message = record.args();
match record.level() {
Level::Error => writeln!(buffer, "error: {message}"),
Level::Warn => writeln!(buffer, "warning: {message}"),
_ => writeln!(buffer, "eish: {message}"),
}
});
let _ = builder.try_init();
}
pub fn run(self) -> ExitCode {
match self.execute() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("error: {error}");
let mut source = std::error::Error::source(&error);
while let Some(cause) = source {
eprintln!(" caused by: {cause}");
source = cause.source();
}
ExitCode::FAILURE
}
}
}
fn execute(&self) -> eish::Result<()> {
let parsed = RepoSpec::parse(&self.spec)?;
let mut spec = InstallSpec::new(parsed.owner, parsed.repo)
.with_shell(self.shell)
.with_proxy(self.proxy)
.with_install_dir(self.dir.clone())
.with_min_disk_space_mb(self.min_disk_space)
.with_default_target(self.target.clone())
.with_name(self.name.clone())
.with_invocation(invocation())
.with_resource(match self.resource_type {
ResourceType::Release => Resource::Release,
ResourceType::File => Resource::File {
reference: self.reference.clone(),
},
});
if let Some(binary) = &self.binary {
spec = spec.with_binary(binary.clone());
}
if let Some(tag) = self.tag.clone().or(parsed.tag) {
spec = spec.with_tag(tag);
}
let release = match (&self.release_json, self.assets.is_empty()) {
(_, false) => None,
(Some(path), _) => Some(release_from_json_file(path)?),
(None, _) => {
let client = Client::new().with_api_base(self.api_base.clone());
if let Some(source) = client.credential_source() {
info!("using credentials from {source}");
}
info!("querying GitHub for {}", spec.slug());
Some(client.release(&spec.owner, &spec.repo, Some(&spec.tag))?)
}
};
match &release {
Some(release) => spec.apply_release_checked(release)?,
None => {
if spec.apply_assets(self.assets.iter().map(String::as_str)) == 0 {
return Err(spec.no_assets_error());
}
}
}
spec.validate()?;
if self.list {
for asset in &spec.assets {
println!("{}\t{}\t{}", asset.program, asset.target, asset.filename);
}
return Ok(());
}
info!(
"found {} target(s) for {} ({})",
spec.assets.len(),
spec.slug(),
spec.resolved_tag.as_deref().unwrap_or(&spec.tag)
);
let script = spec.render()?;
self.write(&script)
}
fn write(&self, script: &str) -> eish::Result<()> {
match &self.output {
Some(path) => {
std::fs::write(path, script).map_err(|source| eish::Error::WriteFile {
path: path.clone(),
source,
})?;
info!("wrote {}", path.display());
Ok(())
}
None => {
let mut out = std::io::stdout().lock();
out.write_all(script.as_bytes())
.map_err(|source| eish::Error::WriteFile {
path: PathBuf::from("<stdout>"),
source,
})?;
out.flush().map_err(|source| eish::Error::WriteFile {
path: PathBuf::from("<stdout>"),
source,
})
}
}
}
}
fn invocation() -> String {
let mut args = std::env::args();
let _program = args.next();
build_invocation(args)
}
fn build_invocation(args: impl IntoIterator<Item = String>) -> String {
std::iter::once("eish".to_string())
.chain(args.into_iter().map(|arg| quote_arg(&arg)))
.collect::<Vec<_>>()
.join(" ")
}
fn quote_arg(arg: &str) -> String {
let safe = !arg.is_empty()
&& arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-/:+@=,".contains(c));
if safe {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', r"'\''"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn invocation(args: &[&str]) -> String {
build_invocation(args.iter().map(|s| (*s).to_string()))
}
#[test]
fn records_the_arguments_as_typed() {
assert_eq!(
invocation(&["acme/tool@v1", "--shell", "fish", "-o", "install.fish"]),
"eish acme/tool@v1 --shell fish -o install.fish"
);
}
#[test]
fn quotes_arguments_that_need_it() {
assert_eq!(
invocation(&["acme/tool", "--dir", "/opt/my tools"]),
"eish acme/tool --dir '/opt/my tools'"
);
assert_eq!(invocation(&["a b's"]), r"eish 'a b'\''s'");
assert_eq!(invocation(&["--dir", "~/bin"]), "eish --dir '~/bin'");
}
#[test]
fn leaves_ordinary_arguments_unquoted() {
assert_eq!(
invocation(&[
"https://github.com/a/b@v1.2.3",
"--target=x86_64-unknown-linux-gnu"
]),
"eish https://github.com/a/b@v1.2.3 --target=x86_64-unknown-linux-gnu"
);
}
#[test]
fn no_cli_option_is_secret() {
use clap::CommandFactory as _;
let command = Cli::command();
let suspicious: Vec<&str> = command
.get_arguments()
.filter_map(|arg| arg.get_long())
.filter(|long| {
let long = long.to_ascii_lowercase();
long.contains("token") || long.contains("password") || long.contains("secret")
})
.collect();
assert!(
suspicious.is_empty(),
"these options look secret and would be written into generated scripts: {suspicious:?}"
);
}
}