#!/bin/sh # # Install Maxon on macOS or Linux: # # curl -fsSL https://maxon.dev/install.sh | sh # # Downloads the release archive for this machine from GitHub, checks it against the release's # SHA256SUMS, and installs it into ~/.maxon: the compiler in ~/.maxon/bin, the standard library in # ~/.maxon/stdlib and the language runtime in ~/.maxon/runtime. Running it again installs the latest # release, or says the install is current. # `usage` below lists the options. # # ⚠ A CHILD PROCESS CANNOT CHANGE ITS PARENT'S PATH, and `curl | sh` is one. So `maxon` is linked into # ~/.local/bin or ~/bin when the caller's PATH already holds one, which the calling shell searches at # once; only without either does this fall back to a shell-profile line, read by the next shell. # # ⛔ `maxon upgrade` RUNS THIS SCRIPT, SO ITS INTERFACE IS A CONTRACT WITH EVERY SHIPPED COMPILER: # it honours MAXON_INSTALL and --no-modify-path, and exits 0 when the install is current and non-zero # when it is not. Renaming or dropping any of those breaks `maxon upgrade` in every release that has it. # Everything is inside main, so a download cut off partway runs nothing. main() { set -eu repo="maxon-lang/maxon" version="" modify_path=1 force=0 while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || fail "--version needs a value"; version="${2#v}"; shift 2 ;; --version=*) version="${1#--version=}"; version="${version#v}"; shift ;; --no-modify-path) modify_path=0; shift ;; --force) force=1; shift ;; -h|--help) usage; exit 0 ;; *) fail "unknown option '$1' (see --help)" ;; esac done if [ -n "$version" ]; then check_version "$version" fi need curl need tar need uname target="$(detect_target)" root="$(install_root)" bin_dir="$root/bin" pinned=1 [ -n "$version" ] || pinned=0 if [ -n "${MAXON_DOWNLOAD_BASE:-}" ]; then base="$MAXON_DOWNLOAD_BASE" [ "$pinned" -eq 1 ] || fail "MAXON_DOWNLOAD_BASE needs --version: a mirror has no 'latest' to ask" fetch() { curl -fsSL "$1" -o "$2"; } else base="https://github.com/$repo/releases/download" # Only https, and only TLS 1.2 or later, for anything this script runs. fetch() { curl --proto '=https' --tlsv1.2 -fsSL "$1" -o "$2"; } [ "$pinned" -eq 1 ] || version="$(latest_version)" fi if [ "$force" -eq 0 ] && [ -d "$root/stdlib" ] && [ "$(installed_version "$bin_dir/maxon")" = "$version" ]; then if [ "$pinned" -eq 1 ]; then say "Maxon $version is already installed in $root (--force reinstalls it)" else say "Maxon $version, the latest release, is already installed in $root (--force reinstalls it)" fi finish_path "$bin_dir" "$modify_path" return 0 fi # A directory that already holds a stdlib/, runtime/ or examples/ of its own is not ours to replace. if [ ! -e "$bin_dir/maxon" ]; then for entry in stdlib runtime examples; do if [ -e "$root/$entry" ]; then fail "$root/$entry exists and $root holds no bin/maxon, so it is not a Maxon install; set MAXON_INSTALL to a new directory" fi done fi asset="maxon-$version-$target.tar.gz" say "installing Maxon $version for $target into $root" tmp="$(mktemp -d 2>/dev/null || mktemp -d -t maxon-install)" # Staged beside the install so every rename below stays on one filesystem. mkdir -p "$bin_dir" staging="$root/.staging.$$" retired="$root/.retired.$$" # `retired` is only ever removed empty: after a kill mid-swap it holds the previous install. trap 'rm -rf "$tmp" "$staging"; rmdir "$retired" 2>/dev/null || true' EXIT INT TERM rm -rf "$staging" mkdir "$staging" fetch "$base/v$version/$asset" "$tmp/$asset" \ || fail "could not download $asset — is $version a published release with a $target build?" fetch "$base/v$version/SHA256SUMS" "$tmp/SHA256SUMS" \ || fail "could not download SHA256SUMS for $version" verify_checksum "$tmp" "$asset" tar -xzf "$tmp/$asset" -C "$staging" unpacked="$staging/maxon-$version-$target" if [ ! -x "$unpacked/maxon" ] || [ ! -d "$unpacked/stdlib" ] || [ ! -d "$unpacked/examples" ]; then fail "$asset does not hold maxon, stdlib/ and examples/ in maxon-$version-$target/" fi # 126 and 127 are the shell saying it could not execute the file at all, and 128 and up a signal — # a wrong architecture or a noexec mount. Any other status is the compiler running and answering, # and older releases answer `version` differently. rc=0 "$unpacked/maxon" version >/dev/null 2>&1 || rc=$? if [ "$rc" -ge 126 ]; then fail "the downloaded compiler does not run on this machine (exit $rc)" fi mkdir "$retired" if ! swap_in "$root" "$unpacked" "$retired"; then roll_back "$root" "$retired" rm -rf "$retired" fail "could not move the new release into $root; the previous install is unchanged" fi rm -rf "$retired" say "installed Maxon $version in $root" finish_path "$bin_dir" "$modify_path" } usage() { cat <<'USAGE' Install Maxon on macOS or Linux. curl -fsSL https://maxon.dev/install.sh | sh curl -fsSL https://maxon.dev/install.sh | sh -s -- [options] Options: --version X.Y.Z install that release instead of the latest --force reinstall even when that release is already installed --no-modify-path do not put maxon on PATH: no link in ~/.local/bin or ~/bin, no line in your shell profile Environment: MAXON_INSTALL where Maxon is installed (default ~/.maxon) MAXON_DOWNLOAD_BASE a mirror of the release assets, laid out as /v/ Uninstall: rm -rf ~/.maxon, then remove what put it on PATH: the maxon link this script made in ~/.local/bin or ~/bin, or the line it added to your shell profile. USAGE } say() { printf 'maxon-install: %s\n' "$1"; } fail() { printf 'maxon-install: %s\n' "$1" >&2 exit 1 } need() { command -v "$1" >/dev/null 2>&1 || fail "this installer needs '$1', which is not on PATH" } # A version is interpolated into URLs and file names, so anything but a version's own characters is refused. check_version() { case "$1" in [0-9]*.[0-9]*.[0-9]*) ;; *) fail "'$1' is not a release version (expected X.Y.Z)" ;; esac case "$1" in *[!0-9A-Za-z.-]*) fail "'$1' is not a release version (expected X.Y.Z)" ;; esac } install_root() { dir="${MAXON_INSTALL:-$HOME/.maxon}" case "$dir" in /*) ;; *) fail "MAXON_INSTALL must be an absolute path, not '$dir'" ;; esac while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}" done [ -n "$dir" ] || fail "MAXON_INSTALL cannot be /" echo "$dir" } # The version an installed compiler reports, or nothing when there is no compiler or it answers in a # form this script does not know. installed_version() { [ -x "$1" ] || return 0 "$1" version 2>/dev/null | awk 'NR == 1 && $1 == "maxon" { print $2 }' || true } # Each entry is renamed aside before the new one takes its place, and the binary goes last as one # rename(2): a running compiler keeps its inode, and the old binary stays until the new one is whole. swap_in() { root="$1" unpacked="$2" retired="$3" # ⚠ THE ARCHIVE DECIDES WHICH TIERS EXIST. `runtime/` is absent from releases that predate it, and # this script installs any version, so an entry the archive does not carry is only retired. for entry in stdlib runtime examples; do if [ -e "$root/$entry" ]; then mv "$root/$entry" "$retired/$entry" || return 1 fi [ -d "$unpacked/$entry" ] || continue mv "$unpacked/$entry" "$root/$entry" || return 1 : > "$retired/$entry.placed" || return 1 done mv -f "$unpacked/maxon" "$root/bin/maxon" || return 1 } roll_back() { root="$1" retired="$2" for entry in examples runtime stdlib; do if [ -e "$retired/$entry.placed" ]; then rm -rf "${root:?}/$entry" fi if [ -e "$retired/$entry" ]; then mv "$retired/$entry" "$root/$entry" fi done } detect_target() { os="$(uname -s)" arch="$(uname -m)" case "$os" in Darwin) # A shell running under Rosetta reports x86_64 on Apple silicon; the hardware is what matters. if [ "$arch" = "x86_64" ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]; then arch="arm64" fi case "$arch" in arm64) echo "arm64-macos" ;; *) fail "Maxon runs on Apple silicon Macs only; this one reports $arch" ;; esac ;; Linux) case "$arch" in x86_64|amd64) echo "x64-linux" ;; aarch64|arm64) echo "arm64-linux" ;; *) fail "there is no Maxon build for Linux on $arch" ;; esac ;; MINGW*|MSYS*|CYGWIN*) fail "on Windows, install with: powershell -c \"irm https://maxon.dev/install.ps1 | iex\"" ;; *) fail "there is no Maxon build for $os" ;; esac } # The newest release, from where GitHub redirects /releases/latest — no API call, so no rate limit. latest_version() { url="$(curl --proto '=https' --tlsv1.2 -fsSLo /dev/null -w '%{url_effective}' "https://github.com/$repo/releases/latest")" \ || fail "could not ask GitHub for the latest release" tag="${url##*/}" case "$tag" in v[0-9]*) check_version "${tag#v}"; echo "${tag#v}" ;; *) fail "could not read a version from $url" ;; esac } verify_checksum() { dir="$1" file="$2" # `*` before a name is sha256sum's binary-mode marker. expected="$(awk -v f="$file" '{ name = $2; sub(/^\*/, "", name) } name == f { print $1 }' "$dir/SHA256SUMS")" [ -n "$expected" ] || fail "SHA256SUMS lists no $file" if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$dir/$file" | awk '{ print $1 }')" elif command -v shasum >/dev/null 2>&1; then actual="$(shasum -a 256 "$dir/$file" | awk '{ print $1 }')" else fail "this installer needs sha256sum or shasum to check the download" fi [ "$actual" = "$expected" ] || fail "$file does not match its published checksum — nothing was installed" } finish_path() { dir="$1" modify="$2" case ":$PATH:" in *":$dir:"*) ;; *) if [ "$modify" -eq 1 ]; then if ! link_into_path "$dir"; then add_to_path "$dir" return 0 fi # `maxon upgrade` passes --no-modify-path, and an install it upgrades may be on PATH by its link. elif ! runs_install "$(command -v maxon 2>/dev/null || true)" "$dir"; then say "$dir is not on your PATH; add it to run \`maxon\` by name" return 0 fi ;; esac found="$(command -v maxon 2>/dev/null || true)" if [ -n "$found" ] && ! runs_install "$found" "$dir"; then say "warning: $found comes first on your PATH, so \`maxon\` runs that one$(owner_hint "$found")" fi } # Whether the `maxon` at $1 is the one in $2, directly or through the link `link_into_path` makes. runs_install() { [ "$1" = "$2/maxon" ] || { [ -L "$1" ] && [ "$(readlink "$1")" = "$2/maxon" ]; } } # Only ~/.local/bin and ~/bin, because any other directory under $HOME on PATH belongs to some other # tool. A `maxon` already there that is not this install's link is left alone. link_into_path() { link_target="$1/maxon" saved_ifs="$IFS" IFS=: set -f linked=1 for entry in $PATH; do entry="${entry%/}" case "$entry" in "$HOME/.local/bin"|"$HOME/bin") ;; *) continue ;; esac if [ ! -d "$entry" ] || [ ! -w "$entry" ]; then continue fi if runs_install "$entry/maxon" "$1"; then linked=0 break fi if [ ! -e "$entry/maxon" ] && [ ! -L "$entry/maxon" ] && ln -s "$link_target" "$entry/maxon"; then say "linked $entry/maxon to $link_target; this terminal's PATH already searches $entry" linked=0 break fi done set +f IFS="$saved_ifs" return "$linked" } owner_hint() { case "$1" in /opt/homebrew/*|/home/linuxbrew/.linuxbrew/*|"$HOME"/.linuxbrew/*) echo " (Homebrew's; \`brew uninstall maxon-lang/tap/maxon\` removes it)" ;; esac } # One line in the profile the user's login shell reads, written once however often this runs. add_to_path() { dir="$1" # Written with $HOME unexpanded, so the profile keeps working if the home directory moves. case "$dir" in "$HOME"/*) shown="\$HOME${dir#"$HOME"}" ;; *) shown="$dir" ;; esac shell_name="$(basename "${SHELL:-sh}")" case "$shell_name" in zsh) profile="${ZDOTDIR:-$HOME}/.zshrc" ;; bash) if [ "$(uname -s)" = "Darwin" ]; then profile="$HOME/.bash_profile" else profile="$HOME/.bashrc" fi ;; fish) profile="$HOME/.config/fish/conf.d/maxon.fish" ;; *) profile="$HOME/.profile" ;; esac if [ "$shell_name" = "fish" ]; then line="fish_add_path \"$shown\"" else line="export PATH=\"$shown:\$PATH\"" fi mkdir -p "$(dirname "$profile")" if [ -f "$profile" ] && grep -qxF "$line" "$profile"; then : else printf '\n# Added by the Maxon installer\n%s\n' "$line" >> "$profile" say "added $dir to PATH in $profile" fi say "open a new terminal, or run this to use maxon now: $line" } main "$@"