1use std::path::{Path, PathBuf};
23
24const INSTALLER: &str = "https://miradb.dev/install.sh";
28
29const RELEASES: &str = "https://github.com/TrianaLab/mira/releases";
31
32pub const USAGE: &str = "mira update [--version VERSION] [--dry-run]
33
34Downloads the latest release from GitHub and replaces this binary with it,
35by running the same installer as
36 curl -fsSL https://miradb.dev/install.sh | bash
37
38 --version VERSION install this tag instead of the latest (e.g. v0.1.0)
39 --dry-run print the command that would run, and stop
40
41Installs over this binary's own directory, not /usr/local/bin, unless
42MIRA_INSTALL_DIR says otherwise. Nothing happens if the running version is
43already the one that would be installed.
44
45Needs bash and either curl or wget, because it runs the installer rather
46than carrying an HTTPS client. The container image has none of them; upgrade
47that by pulling a newer tag.";
48
49pub fn parse(argv: &[String]) -> Result<(Option<String>, bool), String> {
58 let mut version = None;
59 let mut dry_run = false;
60 let mut it = argv.iter();
61 while let Some(flag) = it.next() {
62 match flag.as_str() {
63 "--version" | "-v" => {
64 let v = it.next().ok_or("--version needs a value")?;
65 if v.is_empty()
66 || !v
67 .chars()
68 .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
69 {
70 return Err(format!("--version: {v:?} is not a release tag"));
71 }
72 version = Some(v.clone());
73 }
74 "--dry-run" => dry_run = true,
75 other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
76 }
77 }
78 Ok((version, dry_run))
79}
80
81pub fn command(version: Option<&str>) -> String {
87 let tag = match version {
88 Some(v) => format!(" --version {v}"),
89 None => String::new(),
90 };
91 format!(
92 "if command -v curl >/dev/null 2>&1; then curl -fsSL {INSTALLER}; \
93 elif command -v wget >/dev/null 2>&1; then wget -qO- {INSTALLER}; \
94 else echo 'mira update needs curl or wget' >&2; exit 1; fi \
95 | bash -s --{tag}"
96 )
97}
98
99pub fn install_dir(exe: Option<&Path>) -> Option<PathBuf> {
106 let real = std::fs::canonicalize(exe?).ok()?;
107 real.parent().map(Path::to_path_buf)
108}
109
110pub fn run(argv: &[String]) -> Result<(), String> {
117 if argv.iter().any(|a| a == "-h" || a == "--help") {
118 println!("{USAGE}");
119 return Ok(());
120 }
121 let (version, dry_run) = parse(argv)?;
122 let line = command(version.as_deref());
123 if dry_run {
124 println!("{line}");
125 return Ok(());
126 }
127
128 spawn(&mut installer(&line))
129}
130
131fn installer(line: &str) -> std::process::Command {
137 let mut cmd = std::process::Command::new("bash");
138 cmd.arg("-c").arg(line);
139 if std::env::var_os("MIRA_INSTALL_DIR").is_none() {
140 if let Some(dir) = install_dir(std::env::current_exe().ok().as_deref()) {
141 cmd.env("MIRA_INSTALL_DIR", dir);
142 }
143 }
144 cmd
145}
146
147fn spawn(cmd: &mut std::process::Command) -> Result<(), String> {
153 let status = cmd
154 .status()
155 .map_err(|e| start_failed(&cmd.get_program().to_string_lossy(), &e))?;
156 if !status.success() {
157 return Err(format!("installer exited with {status}"));
158 }
159 Ok(())
160}
161
162fn start_failed(program: &str, e: &std::io::Error) -> String {
172 if e.kind() == std::io::ErrorKind::NotFound {
173 return format!(
174 "`{program}` is not on PATH, so there is nothing here to run the \
175 installer with. In a container, upgrade by pulling a newer image \
176 tag. Otherwise install {program}, or take the tarball for this \
177 platform straight from {RELEASES}."
178 );
179 }
180 format!("could not run the installer: {e}")
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn args(a: &[&str]) -> Vec<String> {
188 a.iter().map(|s| s.to_string()).collect()
189 }
190
191 #[test]
192 fn no_flags_installs_the_latest_release() {
193 assert_eq!(parse(&[]).unwrap(), (None, false));
194 let line = command(None);
195 assert!(line.ends_with("| bash -s --"), "{line}");
196 assert!(line.contains(INSTALLER), "{line}");
197 }
198
199 #[test]
200 fn a_tag_is_forwarded_to_the_installer() {
201 let (v, dry) = parse(&args(&["--version", "v0.1.0"])).unwrap();
202 assert_eq!(v.as_deref(), Some("v0.1.0"));
203 assert!(!dry);
204 assert!(command(v.as_deref()).ends_with("--version v0.1.0"));
205 }
206
207 #[test]
208 fn a_tag_that_could_be_a_shell_command_is_refused_rather_than_quoted() {
209 for bad in ["v1; rm -rf /", "$(id)", "`id`", "v1 --no-sudo", ""] {
210 let e = parse(&args(&["--version", bad])).unwrap_err();
211 assert!(e.starts_with("--version:"), "{bad:?} was accepted: {e}");
212 }
213 assert_eq!(
214 parse(&args(&["--version"])).unwrap_err(),
215 "--version needs a value"
216 );
217 }
218
219 #[test]
220 fn an_installer_flag_this_command_does_not_document_is_refused() {
221 let e = parse(&args(&["--no-sudo"])).unwrap_err();
222 assert!(e.starts_with("unknown flag \"--no-sudo\""), "{e}");
223 assert!(e.contains(USAGE), "the usage is part of the message");
224 }
225
226 #[test]
227 fn the_downloader_is_chosen_by_the_shell_and_not_assumed() {
228 let line = command(None);
229 assert!(line.contains("command -v curl"), "{line}");
230 assert!(line.contains("command -v wget"), "{line}");
231 assert!(line.contains("needs curl or wget"), "{line}");
232 }
233
234 #[test]
235 fn the_install_directory_is_this_binarys_own() {
236 let exe = std::env::current_exe().unwrap();
237 assert_eq!(
238 install_dir(Some(&exe)).unwrap(),
239 std::fs::canonicalize(&exe).unwrap().parent().unwrap()
240 );
241 assert_eq!(install_dir(None), None);
242 assert_eq!(install_dir(Some(Path::new("/no/such/mira"))), None);
243 }
244
245 #[test]
246 fn dry_run_prints_the_command_instead_of_running_it() {
247 run(&args(&["--dry-run", "--version", "v9.9.9"])).unwrap();
248 run(&args(&["--help"])).unwrap();
249 assert!(run(&args(&["--nope"])).is_err());
250 }
251
252 #[test]
255 fn the_child_is_the_installer_pointed_at_this_binarys_directory() {
256 let line = command(None);
257 let cmd = installer(&line);
258 assert_eq!(cmd.get_program(), "bash");
259 let argv: Vec<_> = cmd.get_args().collect();
260 assert_eq!(argv, ["-c", line.as_str()]);
261 let dir = cmd
264 .get_envs()
265 .find(|(k, _)| *k == "MIRA_INSTALL_DIR")
266 .and_then(|(_, v)| v)
267 .expect("an install directory");
268 let exe = std::env::current_exe().unwrap();
269 assert_eq!(Path::new(dir), install_dir(Some(&exe)).unwrap());
270 }
271
272 #[test]
273 fn a_failed_installer_is_a_failed_update() {
274 spawn(&mut std::process::Command::new("true")).unwrap();
275
276 let e = spawn(&mut std::process::Command::new("false")).unwrap_err();
277 assert!(e.starts_with("installer exited with"), "{e}");
278
279 let e = spawn(&mut std::process::Command::new("/no/such/installer")).unwrap_err();
280 assert!(e.starts_with("`/no/such/installer` is not on PATH"), "{e}");
281 }
282
283 #[test]
286 fn no_shell_says_so_and_says_what_to_do_instead() {
287 let e = start_failed("bash", &std::io::ErrorKind::NotFound.into());
288 assert!(e.starts_with("`bash` is not on PATH"), "{e}");
289 assert!(e.contains("pulling a newer image tag"), "{e}");
290 assert!(e.contains(RELEASES), "{e}");
291
292 let e = start_failed("bash", &std::io::ErrorKind::PermissionDenied.into());
295 assert!(e.starts_with("could not run the installer:"), "{e}");
296 }
297
298 #[test]
299 fn the_usage_names_what_it_needs_on_the_host() {
300 assert!(
301 USAGE.contains("Needs bash and either curl or wget"),
302 "{USAGE}"
303 );
304 }
305}