use std::path::{Path, PathBuf};
const INSTALLER: &str = "https://miradb.dev/install.sh";
const RELEASES: &str = "https://github.com/TrianaLab/mira/releases";
pub fn cli() -> clap::Command {
clap::Command::new("update")
.about("replace this binary with a release from GitHub")
.after_help(
"Runs the same installer as\n \
curl -fsSL https://miradb.dev/install.sh | bash\n\n\
Installs over this binary's own directory, not /usr/local/bin, unless\n\
MIRA_INSTALL_DIR says otherwise. Nothing happens if the running version\n\
is already the one that would be installed.\n\n\
Needs bash and either curl or wget, because it runs the installer rather\n\
than carrying an HTTPS client. The container image has none of them;\n\
upgrade that by pulling a newer tag.",
)
.arg(
clap::Arg::new("version")
.long("version")
.short('v')
.value_name("VERSION")
.value_parser(tag)
.help("install this tag instead of the latest (e.g. v0.1.0)"),
)
.arg(
clap::Arg::new("dry-run")
.long("dry-run")
.action(clap::ArgAction::SetTrue)
.help("print the command that would run, and stop"),
)
}
pub fn tag(v: &str) -> Result<String, String> {
if v.is_empty()
|| !v
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
{
return Err(format!("{v:?} is not a release tag"));
}
Ok(v.to_owned())
}
pub fn command(version: Option<&str>) -> String {
let tag = match version {
Some(v) => format!(" --version {v}"),
None => String::new(),
};
format!(
"if command -v curl >/dev/null 2>&1; then curl -fsSL {INSTALLER}; \
elif command -v wget >/dev/null 2>&1; then wget -qO- {INSTALLER}; \
else echo 'mira update needs curl or wget' >&2; exit 1; fi \
| bash -s --{tag}"
)
}
pub fn install_dir(exe: Option<&Path>) -> Option<PathBuf> {
let real = std::fs::canonicalize(exe?).ok()?;
real.parent().map(Path::to_path_buf)
}
pub fn run(m: &clap::ArgMatches) -> Result<(), String> {
let line = command(m.get_one::<String>("version").map(String::as_str));
if m.get_flag("dry-run") {
println!("{line}");
return Ok(());
}
spawn(&mut installer(&line))
}
fn installer(line: &str) -> std::process::Command {
let mut cmd = std::process::Command::new("bash");
cmd.arg("-c").arg(line);
if std::env::var_os("MIRA_INSTALL_DIR").is_none() {
if let Some(dir) = install_dir(std::env::current_exe().ok().as_deref()) {
cmd.env("MIRA_INSTALL_DIR", dir);
}
}
cmd
}
fn spawn(cmd: &mut std::process::Command) -> Result<(), String> {
let status = cmd
.status()
.map_err(|e| start_failed(&cmd.get_program().to_string_lossy(), &e))?;
if !status.success() {
return Err(format!("installer exited with {status}"));
}
Ok(())
}
fn start_failed(program: &str, e: &std::io::Error) -> String {
if e.kind() == std::io::ErrorKind::NotFound {
return format!(
"`{program}` is not on PATH, so there is nothing here to run the \
installer with. In a container, upgrade by pulling a newer image \
tag. Otherwise install {program}, or take the tarball for this \
platform straight from {RELEASES}."
);
}
format!("could not run the installer: {e}")
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(a: &[&str]) -> Result<clap::ArgMatches, String> {
cli()
.try_get_matches_from(std::iter::once("update").chain(a.iter().copied()))
.map_err(|e| e.to_string())
}
#[test]
fn no_flags_installs_the_latest_release() {
let m = parse(&[]).unwrap();
assert_eq!(m.get_one::<String>("version"), None);
assert!(!m.get_flag("dry-run"));
let line = command(None);
assert!(line.ends_with("| bash -s --"), "{line}");
assert!(line.contains(INSTALLER), "{line}");
}
#[test]
fn a_tag_is_forwarded_to_the_installer() {
let m = parse(&["--version", "v0.1.0"]).unwrap();
let v = m.get_one::<String>("version").map(String::as_str);
assert_eq!(v, Some("v0.1.0"));
assert!(!m.get_flag("dry-run"));
assert!(command(v).ends_with("--version v0.1.0"));
}
#[test]
fn a_tag_that_could_be_a_shell_command_is_refused_rather_than_quoted() {
for bad in ["v1; rm -rf /", "$(id)", "`id`", "v1 --no-sudo", ""] {
let e = parse(&["--version", bad]).unwrap_err();
assert!(e.contains("is not a release tag"), "{bad:?}: {e}");
assert!(tag(bad).is_err(), "{bad:?} passed the charset check");
}
assert!(tag("v0.1.0-rc.1").is_ok(), "a real tag was refused");
let e = parse(&["--version"]).unwrap_err();
assert!(e.contains("a value is required"), "{e}");
}
#[test]
fn an_installer_flag_this_command_does_not_document_is_refused() {
let e = parse(&["--no-sudo"]).unwrap_err();
assert!(e.contains("--no-sudo"), "{e}");
assert!(e.contains("Usage: update"), "the usage is part of it: {e}");
}
#[test]
fn the_downloader_is_chosen_by_the_shell_and_not_assumed() {
let line = command(None);
assert!(line.contains("command -v curl"), "{line}");
assert!(line.contains("command -v wget"), "{line}");
assert!(line.contains("needs curl or wget"), "{line}");
}
#[test]
fn the_install_directory_is_this_binarys_own() {
let exe = std::env::current_exe().unwrap();
assert_eq!(
install_dir(Some(&exe)).unwrap(),
std::fs::canonicalize(&exe).unwrap().parent().unwrap()
);
assert_eq!(install_dir(None), None);
assert_eq!(install_dir(Some(Path::new("/no/such/mira"))), None);
}
#[test]
fn dry_run_prints_the_command_instead_of_running_it() {
run(&parse(&["--dry-run", "--version", "v9.9.9"]).unwrap()).unwrap();
assert!(parse(&["--help"]).is_err(), "--help produced matches");
assert!(parse(&["--nope"]).is_err());
}
#[test]
fn the_child_is_the_installer_pointed_at_this_binarys_directory() {
let line = command(None);
let cmd = installer(&line);
assert_eq!(cmd.get_program(), "bash");
let argv: Vec<_> = cmd.get_args().collect();
assert_eq!(argv, ["-c", line.as_str()]);
let dir = cmd
.get_envs()
.find(|(k, _)| *k == "MIRA_INSTALL_DIR")
.and_then(|(_, v)| v)
.expect("an install directory");
let exe = std::env::current_exe().unwrap();
assert_eq!(Path::new(dir), install_dir(Some(&exe)).unwrap());
}
#[test]
fn a_failed_installer_is_a_failed_update() {
spawn(&mut std::process::Command::new("true")).unwrap();
let e = spawn(&mut std::process::Command::new("false")).unwrap_err();
assert!(e.starts_with("installer exited with"), "{e}");
let e = spawn(&mut std::process::Command::new("/no/such/installer")).unwrap_err();
assert!(e.starts_with("`/no/such/installer` is not on PATH"), "{e}");
}
#[test]
fn no_shell_says_so_and_says_what_to_do_instead() {
let e = start_failed("bash", &std::io::ErrorKind::NotFound.into());
assert!(e.starts_with("`bash` is not on PATH"), "{e}");
assert!(e.contains("pulling a newer image tag"), "{e}");
assert!(e.contains(RELEASES), "{e}");
let e = start_failed("bash", &std::io::ErrorKind::PermissionDenied.into());
assert!(e.starts_with("could not run the installer:"), "{e}");
}
#[test]
fn the_help_names_what_it_needs_on_the_host() {
let help = cli().render_long_help().to_string();
assert!(
help.contains("Needs bash and either curl or wget"),
"{help}"
);
}
}