#!/bin/bash # Maestra installer: Spanish practice you say out loud, in the Mac Terminal. # # curl -fsSL https://maestra.sh/install.sh | bash # # What it does, in order (nothing else): # 1. checks this is a Mac with Apple silicon on macOS 11 or newer; # 2. reads https://maestra.sh/latest.json to find the current build and its sha256; # 3. picks one of the two builds: the small one if this Mac already has a working Node 20 or newer, # otherwise the self-contained one, which brings its own (both are the same Maestra); # 4. downloads that build to a temporary folder and checks the sha256 (stops if it doesn't match); # 5. unpacks it into ~/.local/share/maestra// and points ~/.local/bin/maestra at it; # 6. runs it once; if the small build doesn't start, installs the self-contained one instead; # 7. removes older versions it installed there before, and an install from before the rename # (~/.local/share/lupita), whose `lupita` command now opens Maestra too. # No sudo, no questions, and it never edits your shell files: if ~/.local/bin is not on your PATH it # prints the line to add. Your practice history lives in ~/.maestra and is never touched here (Maestra # moves an older ~/.lupita there itself, the first time it starts, and leaves a link at the old name). # Run it again to upgrade. # # Uninstall: # curl -fsSL https://maestra.sh/install.sh | bash -s -- --uninstall # (removes ~/.local/share/maestra and the ~/.local/bin/maestra link; keeps ~/.maestra, your progress) # # Settings (environment variables, all optional; each also answers to its older LUPITA_ name): # MAESTRA_SITE where to download from (default https://maestra.sh) # MAESTRA_INSTALL_DIR where versions are unpacked (default ~/.local/share/maestra) # MAESTRA_BIN_DIR where the `maestra` link goes (default ~/.local/bin) # MAESTRA_FORCE_BUNDLED set to anything to take the self-contained build, whatever Node is here # MAESTRA_NODE a particular node to consider before the one on the PATH # Everything runs inside main(), called on the last line, so a download cut off halfway runs nothing. set -u SITE="${MAESTRA_SITE:-${LUPITA_SITE:-https://maestra.sh}}" SITE="${SITE%/}" INSTALL_DIR="${MAESTRA_INSTALL_DIR:-${LUPITA_INSTALL_DIR:-$HOME/.local/share/maestra}}" INSTALL_DIR="${INSTALL_DIR%/}" BIN_DIR="${MAESTRA_BIN_DIR:-${LUPITA_BIN_DIR:-$HOME/.local/bin}}" BIN_DIR="${BIN_DIR%/}" FORCE_BUNDLED="${MAESTRA_FORCE_BUNDLED:-${LUPITA_FORCE_BUNDLED:-}}" USER_NODE="${MAESTRA_NODE:-${LUPITA_NODE:-}}" # Where installers before the rename unpacked lupita. Links into it are ours too; it is removed once # Maestra is in place. LEGACY_DIR="$HOME/.local/share/lupita" # The command, and the older name kept as a hidden alias: both links point at the same launcher. CMD=maestra ALIAS=lupita MIN_MACOS_MAJOR=11 LISTEN_MACOS_MAJOR=26 TMP="" if [ -t 1 ]; then BOLD=$'\033[1m'; BLUE=$'\033[1;34m'; RED=$'\033[1;31m'; YELLOW=$'\033[1;33m'; RESET=$'\033[0m' else BOLD=""; BLUE=""; RED=""; YELLOW=""; RESET="" fi say_step() { printf '%s==>%s %s%s%s\n' "$BLUE" "$RESET" "$BOLD" "$*" "$RESET"; } say_warn() { printf '%sWarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; } abort() { printf '%sError:%s %s\n' "$RED" "$RESET" "$1" >&2 shift for line in "$@"; do printf ' %s\n' "$line" >&2; done exit 1 } cleanup() { [ -n "$TMP" ] && rm -rf "$TMP"; } # A path as the user would type it: $HOME shown as ~. pretty() { case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac; } usage() { cat </dev/null)" || return 1 case "$major" in ''|*[!0-9]*) return 1 ;; esac [ "$major" -ge "$min" ] 2>/dev/null || return 1 "$candidate" --input-type=module -e 'if (typeof fetch !== "function") process.exit(1)' >/dev/null 2>&1 || return 1 return 0 } # The first usable Node, printed; nothing when there is none. find_node() { local min="$1" candidate for candidate in "$USER_NODE" "$(command -v node 2>/dev/null || true)"; do [ -n "$candidate" ] || continue if usable_node "$candidate" "$min"; then printf '%s' "$candidate"; return 0; fi done return 1 } check_platform() { local os arch os="$(uname -s)" if [ "$os" != "Darwin" ]; then abort "Maestra's terminal version runs on macOS only, and this computer is running $os." \ "Nothing was installed." \ "Versions for the web, Chromebooks, iPhone and Android are planned: see $SITE/#downloads" fi arch="$(uname -m)" # A Terminal running under Rosetta reports x86_64 on an Apple silicon Mac. if [ "$arch" = "x86_64" ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then arch="arm64" fi if [ "$arch" != "arm64" ]; then abort "Maestra needs a Mac with Apple silicon (M1 or newer) for now, and this Mac has an Intel processor." \ "Nothing was installed. We're sorry: an Intel build isn't available yet." \ "To check your Mac: Apple menu > About This Mac, and look for \"Chip: Apple M...\"." fi local version major version="$(sw_vers -productVersion 2>/dev/null || echo 0)" major="${version%%.*}" if [ "$major" -lt "$MIN_MACOS_MAJOR" ] 2>/dev/null; then abort "Maestra needs macOS $MIN_MACOS_MAJOR or newer, and this Mac has macOS $version." \ "Nothing was installed. Update macOS in System Settings > General > Software Update." fi MACOS_MAJOR="$major" MACOS_VERSION="$version" } check_tools() { local tool for tool in curl shasum tar; do command -v "$tool" >/dev/null 2>&1 || abort "this installer needs \`$tool\`, which macOS normally includes, and it wasn't found." done } refuse_root() { if [ "${EUID:-$(id -u)}" = "0" ]; then abort "please don't run this installer with sudo or as root." \ "Maestra installs into your own home folder and needs no administrator rights." \ "Run it again as yourself: curl -fsSL $SITE/install.sh | bash" fi } # A link we own points into INSTALL_DIR, or into the folder installers used before the rename. # Anything else at that path belongs to someone else. link_is_ours() { local link="$BIN_DIR/$1" target [ -L "$link" ] || return 1 target="$(readlink "$link")" case "$target" in "$INSTALL_DIR"/*|"$LEGACY_DIR"/*) return 0 ;; *) return 1 ;; esac } # Is there anything at that path, even a dangling link? occupied() { [ -e "$BIN_DIR/$1" ] || [ -L "$BIN_DIR/$1" ]; } # Remove the versions a pre-rename installer unpacked (each holds a `lupita` or `maestra` program), then # the folder itself if nothing else is left in it. Never a folder this script didn't make. remove_legacy() { [ "$LEGACY_DIR" != "$INSTALL_DIR" ] || return 0 [ -d "$LEGACY_DIR" ] || return 0 local old for old in "$LEGACY_DIR"/*; do [ -d "$old" ] || continue [ -x "$old/lupita" ] || [ -x "$old/maestra" ] || [ "${old%.partial}" != "$old" ] || continue rm -rf "$old" done rmdir "$LEGACY_DIR" 2>/dev/null || true } path_hint() { local line profile shell_name line="export PATH=\"$BIN_DIR:\$PATH\"" [ "$BIN_DIR" = "$HOME/.local/bin" ] && line='export PATH="$HOME/.local/bin:$PATH"' shell_name="$(basename "${SHELL:-/bin/zsh}")" case "$shell_name" in bash) profile="~/.bash_profile" ;; zsh) profile="~/.zprofile" ;; *) profile="" ;; esac echo say_warn "$(pretty "$BIN_DIR") is not on your PATH yet, so the \`$CMD\` command won't be found." if [ -n "$profile" ]; then echo " Run these two commands once to fix that for every new Terminal window:" echo echo " echo '$line' >> $profile" echo " $line" else echo " Add this line to your shell's startup file, then open a new Terminal window:" echo echo " $line" fi echo } uninstall() { refuse_root local removed=0 name for name in "$CMD" "$ALIAS"; do if link_is_ours "$name"; then rm -f "$BIN_DIR/$name"; echo "Removed $(pretty "$BIN_DIR/$name")"; removed=1 elif [ "$name" = "$CMD" ] && occupied "$name"; then say_warn "$(pretty "$BIN_DIR/$name") wasn't installed by this script, so it was left alone." fi done if [ -d "$INSTALL_DIR" ]; then rm -rf "$INSTALL_DIR"; echo "Removed $(pretty "$INSTALL_DIR")"; removed=1 fi if [ "$LEGACY_DIR" != "$INSTALL_DIR" ] && [ -d "$LEGACY_DIR" ]; then remove_legacy [ -d "$LEGACY_DIR" ] || { echo "Removed $(pretty "$LEGACY_DIR")"; removed=1; } fi [ "$removed" = 1 ] || echo "Maestra wasn't installed by this script; nothing to remove." echo echo "Your practice history is still in ~/.maestra. To delete it too (this can't be undone):" if [ -L "$HOME/.lupita" ]; then echo " rm -rf ~/.maestra ~/.lupita" elif [ -d "$HOME/.lupita" ]; then echo " rm -rf ~/.maestra (and ~/.lupita, the folder from before the rename)" else echo " rm -rf ~/.maestra" fi } # Download one build, check its checksum, and put it in place at $4. Aborts on anything unexpected; # on success $4 holds an unpacked Maestra. Called a second time, with the self-contained build, if the # small one turns out not to start on this Mac. fetch_and_unpack() { local url="$1" sha256="$2" size="$3" dest="$4" version="$5" file actual unpacked how_big file="$TMP/${url##*/}" rm -f "$file" # The small build is well under a megabyte, so "0 MB" would be the wrong thing to say. if [ "${size:-0}" -ge 1048576 ] 2>/dev/null; then how_big="$(( size / 1048576 )) MB" else how_big="$(( (${size:-0} + 1023) / 1024 )) KB"; fi say_step "Downloading Maestra $version ($how_big)" curl -fL --retry 2 --progress-bar "$url" -o "$file" \ || abort "the download failed: $url" "Nothing was installed. Check your internet connection and try again." say_step "Checking the download" actual="$(shasum -a 256 "$file" | cut -d ' ' -f 1)" if [ "$actual" != "$sha256" ]; then abort "the downloaded file doesn't match its checksum, so it wasn't installed." \ "expected sha256 $sha256" \ "got sha256 $actual" \ "This can happen if the download was interrupted. Please run the installer again." fi say_step "Installing into $(pretty "$dest")" rm -rf "$TMP/unpack" mkdir -p "$TMP/unpack" && tar -xJf "$file" -C "$TMP/unpack" || abort "couldn't unpack the download." unpacked="$(find "$TMP/unpack" -mindepth 1 -maxdepth 1 -type d | head -n 1)" if [ -z "$unpacked" ] || [ ! -x "$unpacked/$CMD" ] || [ ! -f "$unpacked/$CMD.mjs" ] \ || [ ! -f "$unpacked/lessons/characters.json" ]; then abort "the download doesn't look like a Maestra build (no maestra program or lessons inside)." "Nothing was installed." fi mkdir -p "$INSTALL_DIR" || abort "couldn't create $INSTALL_DIR." rm -rf "$dest.partial" "$dest" mv "$unpacked" "$dest.partial" && mv "$dest.partial" "$dest" || abort "couldn't move Maestra into $dest." } install() { refuse_root check_platform check_tools TMP="$(mktemp -d "${TMPDIR:-/tmp}/maestra-install.XXXXXX")" || abort "couldn't make a temporary folder." trap cleanup EXIT say_step "Checking for the latest Maestra" curl -fsSL --retry 2 "$SITE/latest.json" -o "$TMP/latest.json" \ || abort "couldn't reach $SITE/latest.json." "Check your internet connection and try again." local version build url sha256 size node_url node_sha256 node_size node_min version="$(json_value version "$TMP/latest.json")" build="$(json_value build "$TMP/latest.json")" url="$(json_value url "$TMP/latest.json")" sha256="$(json_value sha256 "$TMP/latest.json")" size="$(json_value size "$TMP/latest.json")" # The small build, for a Mac that already has Node. Older latest.json files have none of these and # the installer simply carries on with the self-contained build. node_url="$(json_value nodeUrl "$TMP/latest.json")" node_sha256="$(json_value nodeSha256 "$TMP/latest.json")" node_size="$(json_value nodeSize "$TMP/latest.json")" node_min="$(json_value nodeMin "$TMP/latest.json")" case "$node_min" in ''|*[!0-9]*) node_min=20 ;; esac if [ -z "$version" ] || [ -z "$build" ] || [ -z "$url" ] || [ -z "$sha256" ]; then abort "the release information at $SITE/latest.json is incomplete." "Nothing was installed. Please try again later." fi case "$build" in *[!A-Za-z0-9._-]*) abort "unexpected build name in latest.json: $build" ;; esac case "$url" in https://*|http://*) ;; *) url="$SITE/${url#/}" ;; esac case "$node_url" in https://*|http://*|'') ;; *) node_url="$SITE/${node_url#/}" ;; esac # Which of the two to fetch. The self-contained one is the default and the fallback; the small one # only when a Node on this Mac has answered for itself. Nothing is asked of the user either way. # Which one was installed is recorded by the install itself — the small build has no `node` file # beside its launcher — and `maestra update` runs this script again, so a Mac whose Node has since # gone away or aged out gets the self-contained build next time. local flavour=bundled use_url="$url" use_sha256="$sha256" use_size="$size" node_bin="" if [ -n "$FORCE_BUNDLED" ]; then say_step "Using Maestra's own copy of Node (MAESTRA_FORCE_BUNDLED)" elif [ -n "$node_url" ] && [ -n "$node_sha256" ] && node_bin="$(find_node "$node_min")" && [ -n "$node_bin" ]; then flavour=node use_url="$node_url"; use_sha256="$node_sha256"; use_size="$node_size" fi local dest="$INSTALL_DIR/$build" if [ -x "$dest/$CMD" ] && link_is_ours "$CMD" && [ "$(readlink "$BIN_DIR/$CMD")" = "$dest/$CMD" ] \ && "$dest/$CMD" --version >/dev/null 2>&1; then say_step "Maestra $version ($build) is already installed and up to date" FRESH=0 else FRESH=1 fetch_and_unpack "$use_url" "$use_sha256" "$use_size" "$dest" "$version" # It has to start. The small build depends on a Node we did not ship, so if it doesn't, fall back # to the self-contained one rather than leave a Maestra that won't run. if ! "$dest/$CMD" --version >/dev/null 2>&1; then if [ "$flavour" = node ]; then say_warn "that build didn't start with the Node on this Mac, so Maestra is installing its own copy." flavour=bundled fetch_and_unpack "$url" "$sha256" "$size" "$dest" "$version" fi "$dest/$CMD" --version >/dev/null 2>&1 \ || abort "Maestra was installed into $(pretty "$dest") but didn't start." \ "Nothing else was changed. Please send this message to whoever gave you Maestra." fi fi mkdir -p "$BIN_DIR" || abort "couldn't create $BIN_DIR." if occupied "$CMD" && ! link_is_ours "$CMD"; then abort "$(pretty "$BIN_DIR/$CMD") already exists and wasn't installed by this script, so it was left alone." \ "Maestra $version is unpacked in $(pretty "$dest")." \ "Move the old $(pretty "$BIN_DIR/$CMD") out of the way and run the installer again." fi ln -sfn "$dest/$CMD" "$BIN_DIR/$CMD" || abort "couldn't create the link $BIN_DIR/$CMD." # The older name, so `lupita`, scripts and reminders from before the rename keep working. Never # advertised, and never put over something that isn't ours (an npm link, say): then it simply isn't made. if ! occupied "$ALIAS" || link_is_ours "$ALIAS"; then ln -sfn "$dest/$CMD" "$BIN_DIR/$ALIAS" 2>/dev/null || true fi # Older versions this script installed. Only folders we made (they hold a maestra program) go. local old for old in "$INSTALL_DIR"/*; do [ -d "$old" ] || continue [ "$old" = "$dest" ] && continue [ -x "$old/$CMD" ] || [ "${old%.partial}" != "$old" ] || continue rm -rf "$old" done # And the install from before the rename: its links now point here, so nothing uses it any more. if "$BIN_DIR/$CMD" --version >/dev/null 2>&1; then remove_legacy else say_warn "Maestra was installed but didn't start when checked. Try running $BIN_DIR/$CMD yourself." fi if [ "$FRESH" = 1 ]; then say_step "Installed Maestra $version ($build)" # Support wants to know which of the two is on the machine; the learner can ignore it. if [ "$flavour" = node ]; then echo " (the small build — it uses the Node already on this Mac: $node_bin)" fi fi case ":${PATH}:" in *":$BIN_DIR:"*) local found found="$(command -v "$CMD" 2>/dev/null || true)" if [ -n "$found" ] && [ "$found" != "$BIN_DIR/$CMD" ]; then echo say_warn "another $CMD comes first on your PATH: $found" echo " Run $BIN_DIR/$CMD to use this one, or remove the other." fi ;; *) path_hint ;; esac if ! say -v '?' 2>/dev/null | grep -q '^Paulina '; then echo say_warn "the Spanish voice Paulina isn't installed, so Maestra will use another voice." echo " Add it in System Settings > Accessibility > Spoken Content > System Voice > Manage Voices." fi if [ "${MACOS_MAJOR:-0}" -lt "$LISTEN_MACOS_MAJOR" ]; then echo echo "Note: listening to your answer needs macOS $LISTEN_MACOS_MAJOR (this Mac has $MACOS_VERSION)." echo "Everything else works: you answer out loud and grade yourself." fi cat <&2; exit 2 ;; esac } main "$@"