use std::path::{Path, PathBuf};
const INSTALLER: &str = "https://miradb.dev/install.sh";
const RELEASES: &str = "https://github.com/TrianaLab/mira/releases";
pub const USAGE: &str = "mira update [--version VERSION] [--dry-run]
Downloads the latest release from GitHub and replaces this binary with it,
by running the same installer as
curl -fsSL https://miradb.dev/install.sh | bash
--version VERSION install this tag instead of the latest (e.g. v0.1.0)
--dry-run print the command that would run, and stop
Installs over this binary's own directory, not /usr/local/bin, unless
MIRA_INSTALL_DIR says otherwise. Nothing happens if the running version is
already the one that would be installed.
Needs bash and either curl or wget, because it runs the installer rather
than carrying an HTTPS client. The container image has none of them; upgrade
that by pulling a newer tag.";
pub fn parse(argv: &[String]) -> Result<(Option<String>, bool), String> {
let mut version = None;
let mut dry_run = false;
let mut it = argv.iter();
while let Some(flag) = it.next() {
match flag.as_str() {
"--version" | "-v" => {
let v = it.next().ok_or("--version needs a value")?;
if v.is_empty()
|| !v
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
{
return Err(format!("--version: {v:?} is not a release tag"));
}
version = Some(v.clone());
}
"--dry-run" => dry_run = true,
other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
}
}
Ok((version, dry_run))
}
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(argv: &[String]) -> Result<(), String> {
if argv.iter().any(|a| a == "-h" || a == "--help") {
println!("{USAGE}");
return Ok(());
}
let (version, dry_run) = parse(argv)?;
let line = command(version.as_deref());
if 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 args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn no_flags_installs_the_latest_release() {
assert_eq!(parse(&[]).unwrap(), (None, false));
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 (v, dry) = parse(&args(&["--version", "v0.1.0"])).unwrap();
assert_eq!(v.as_deref(), Some("v0.1.0"));
assert!(!dry);
assert!(command(v.as_deref()).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(&args(&["--version", bad])).unwrap_err();
assert!(e.starts_with("--version:"), "{bad:?} was accepted: {e}");
}
assert_eq!(
parse(&args(&["--version"])).unwrap_err(),
"--version needs a value"
);
}
#[test]
fn an_installer_flag_this_command_does_not_document_is_refused() {
let e = parse(&args(&["--no-sudo"])).unwrap_err();
assert!(e.starts_with("unknown flag \"--no-sudo\""), "{e}");
assert!(e.contains(USAGE), "the usage is part of the message");
}
#[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(&args(&["--dry-run", "--version", "v9.9.9"])).unwrap();
run(&args(&["--help"])).unwrap();
assert!(run(&args(&["--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_usage_names_what_it_needs_on_the_host() {
assert!(
USAGE.contains("Needs bash and either curl or wget"),
"{USAGE}"
);
}
}