diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 8734022d..8cb888fc 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -19,6 +19,7 @@ jobs: env: ANT_VERSION: ${{ inputs.version }} INSTALL_DIR: ${{ runner.temp }}/bin + ANT_NO_MODIFY_PATH: "1" run: bash install.sh - name: verify binary @@ -37,6 +38,149 @@ jobs: echo "Bootstrap config installed at $config_file" cat "$config_file" + # A default install must leave `ant` on PATH in new shells with no manual edit (#194). env -i + # starts each shell from a PATH without ~/.local/bin, as on a fresh machine. + - name: verify a default install is on PATH in new shells + env: + ANT_VERSION: ${{ inputs.version }} + run: | + fresh() { env -i HOME="$HOME" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + fresh ANT_VERSION="$ANT_VERSION" bash install.sh + for shell in "bash -lc" "bash -ic"; do + # shellcheck disable=SC2086 # $shell is a shell and its flag, split on purpose. + found="$(fresh $shell 'command -v ant' 2> /dev/null || true)" + echo "$shell: ${found:-}" + [ "$found" = "$HOME/.local/bin/ant" ] || exit 1 + done + fresh bash -lc 'ant --version' + + # Shells can run part of a PATH entry containing $, `, " or \ as code (macOS login shells + # re-read PATH through path_helper unescaped), so the installer must leave such a directory + # out of every startup file, and the line it prints instead must be safe to paste. zsh and + # fish are installed so their startup files are checked too. sh is dash. + - name: verify an install directory with shell syntax in its name is refused + env: + ANT_VERSION: ${{ inputs.version }} + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq zsh fish > /dev/null + home="$RUNNER_TEMP/odd-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + # shellcheck disable=SC2016 # The syntax is meant to reach the installer unexpanded. + dir="$home"'/odd $(touch $HOME/ran-subst) `touch $HOME/ran-backtick` "quoted" back\slash' + out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" bash install.sh)" + echo "$out" + if ! echo "$out" | grep -q "was not added to your shell startup files"; then + echo "ERROR: expected the installer to refuse this directory" + exit 1 + fi + for f in .profile .bashrc .zshenv .config/fish/conf.d/ant.fish; do + if [ -e "$home/$f" ]; then + echo "ERROR: $f was written" + exit 1 + fi + done + line="$(echo "$out" | grep -m1 '^ export PATH=')" + found="$(env -i HOME="$home" PATH=/usr/bin:/bin sh -c "$line; command -v ant" || true)" + echo "printed line: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + if ls "$home"/ran-* 2> /dev/null; then + echo "ERROR: code from the install directory's name ran" + exit 1 + fi + + # Every other character is safe inside the double quotes of the lines written, so a name with + # spaces, quotes and other punctuation must still be added and resolve in each new shell. + - name: verify an install directory with other punctuation in its name is added + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/punct-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + dir="$home/my apps (x86) & 'quoted' #1 *" + fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" bash install.sh + for shell in "bash -lc" "bash -ic" "sh -lc" "zsh -c" "fish -c"; do + # shellcheck disable=SC2086 # $shell is a shell and its flag, split on purpose. + found="$(fresh $shell 'command -v ant' 2> /dev/null || true)" + echo "$shell: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + done + + # PATH is colon-separated, so a name with `:` can't be a PATH entry at all: no startup file, + # fish's included, and no `export PATH=` line, with or without --no-modify-path. A newline is + # rejected before anything is installed, whether it is in INSTALL_DIR or, for a relative one, + # in the current directory; `pwd` would strip a trailing one, and the install would land in + # the directory without it. + - name: verify an install directory with a colon or a newline in its name is refused + env: + ANT_VERSION: ${{ inputs.version }} + run: | + installer="$PWD/install.sh" + home="$RUNNER_TEMP/colon-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/bash PATH=/usr/bin:/bin "$@"; } + for case in "$home/with:colon|" "$home/trailing:|" "$home/no-modify:path|1"; do + dir="${case%|*}" + out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" ANT_NO_MODIFY_PATH="${case##*|}" bash install.sh)" + echo "$out" + if ! echo "$out" | grep -q "can't be added to your PATH"; then + echo "ERROR: expected the installer to refuse $dir" + exit 1 + fi + if echo "$out" | grep -q '^ export PATH='; then + echo "ERROR: the installer printed an export line for $dir" + exit 1 + fi + done + for f in .profile .bashrc .zshenv .config/fish/conf.d/ant.fish; do + if [ -e "$home/$f" ]; then + echo "ERROR: $f was written" + exit 1 + fi + done + mkdir -p "$home/nl" + if fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$home/nl"$'\n' bash install.sh; then + echo "ERROR: an INSTALL_DIR ending in a newline was accepted" + exit 1 + fi + if [ -e "$home/nl/ant" ] || [ -e "$home/nl"$'\n' ]; then + echo "ERROR: something was installed for an INSTALL_DIR ending in a newline" + exit 1 + fi + # A relative INSTALL_DIR from a current directory whose name ends in a newline, with the + # directory without it present, and from one with a newline in the middle. + mkdir -p "$home/work" "$home/work"$'\n' "$home/a"$'\n'"b" + for cwd_dir in "$home/work"$'\n'"|." "$home/a"$'\n'"b|bin"; do + cwd="${cwd_dir%|*}" + if (cd "$cwd" && fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="${cwd_dir##*|}" bash "$installer"); then + echo "ERROR: a relative INSTALL_DIR under a directory with a newline in its name was accepted" + exit 1 + fi + done + if [ -e "$home/work/ant" ] || [ -e "$home/work"$'\n'"/ant" ] || [ -e "$home/a"$'\n'"b/bin" ]; then + echo "ERROR: something was installed for a current directory with a newline in its name" + exit 1 + fi + + # A relative INSTALL_DIR is made absolute with `cd`, which also prints the directory when it + # is found through CDPATH. + - name: verify a relative install directory with CDPATH set + env: + ANT_VERSION: ${{ inputs.version }} + run: | + work="$RUNNER_TEMP/relative" + mkdir -p "$work" + cp install.sh "$work/" + cd "$work" + out="$(CDPATH=. INSTALL_DIR=bin ANT_NO_MODIFY_PATH=1 bash install.sh)" + echo "$out" + if ! echo "$out" | grep -qxF "Installed ant to $work/bin/ant"; then + echo "ERROR: expected ant to be installed to $work/bin/ant" + exit 1 + fi + "$work/bin/ant" --version + test-install-script-macos: name: test install.sh (macOS) runs-on: macos-latest @@ -47,6 +191,7 @@ jobs: env: ANT_VERSION: ${{ inputs.version }} INSTALL_DIR: ${{ runner.temp }}/bin + ANT_NO_MODIFY_PATH: "1" run: bash install.sh - name: verify binary @@ -65,6 +210,84 @@ jobs: echo "Bootstrap config installed at $config_file" cat "$config_file" + # As on Linux (#194), from a fresh-machine PATH. /bin/bash is the 3.2 that `curl | bash` runs + # on a stock Mac, and zsh is the default login shell. zsh login shells run /etc/zprofile's + # path_helper and Homebrew's shellenv after ~/.zshenv, which put ~/.local/bin behind + # /opt/homebrew/bin, where this runner image has Apache Ant (also `ant`). So login shells are + # checked for ~/.local/bin on PATH rather than for which `ant` wins. + - name: verify a default install is on PATH in new shells + env: + ANT_VERSION: ${{ inputs.version }} + run: | + fresh() { env -i HOME="$HOME" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } + fresh ANT_VERSION="$ANT_VERSION" /bin/bash install.sh + found="$(fresh zsh -c 'command -v ant' || true)" + echo "zsh -c: ${found:-}" + [ "$found" = "$HOME/.local/bin/ant" ] + for shell in "zsh -lc" "zsh -ic" "/bin/bash -lc"; do + # shellcheck disable=SC2016,SC2086 # $PATH is for the new shell; $shell is split on purpose. + p="$(fresh $shell 'echo $PATH' 2> /dev/null || true)" + echo "$shell: $p" + case ":$p:" in *":$HOME/.local/bin:"*) ;; *) exit 1 ;; esac + done + fresh zsh -c 'ant --version' + + # As on Linux, a name with shell syntax in it is refused and runs nothing, here with the stock + # /bin/bash running the installer and macOS's own sed. Login shells run path_helper, which is + # why such a name can't be put on PATH here at all. + - name: verify an install directory with shell syntax in its name is refused + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/odd-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } + # shellcheck disable=SC2016 # The syntax is meant to reach the installer unexpanded. + dir="$home"'/odd $(touch $HOME/ran-subst) `touch $HOME/ran-backtick` "quoted" back\slash' + out="$(fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" /bin/bash install.sh)" + echo "$out" + if ! echo "$out" | grep -q "was not added to your shell startup files"; then + echo "ERROR: expected the installer to refuse this directory" + exit 1 + fi + for f in .profile .bash_profile .zshenv; do + if [ -e "$home/$f" ]; then + echo "ERROR: $f was written" + exit 1 + fi + done + fresh zsh -lc true + fresh /bin/bash -lc true + line="$(echo "$out" | grep -m1 '^ export PATH=')" + found="$(env -i HOME="$home" PATH=/usr/bin:/bin /bin/sh -c "$line; command -v ant" || true)" + echo "printed line: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + if ls "$home"/ran-* 2> /dev/null; then + echo "ERROR: code from the install directory's name ran" + exit 1 + fi + + # A name with spaces, quotes and other punctuation must still be added, and come through + # path_helper intact in login shells. + - name: verify an install directory with other punctuation in its name is added + env: + ANT_VERSION: ${{ inputs.version }} + run: | + home="$RUNNER_TEMP/punct-home" + mkdir -p "$home" + fresh() { env -i HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin "$@"; } + dir="$home/my apps (x86) & 'quoted' #1 *" + fresh ANT_VERSION="$ANT_VERSION" INSTALL_DIR="$dir" /bin/bash install.sh + found="$(fresh zsh -c 'command -v ant' || true)" + echo "zsh -c: ${found:-}" + [ "$found" = "$dir/ant" ] || exit 1 + for shell in "zsh -lc" "zsh -ic" "/bin/bash -lc"; do + # shellcheck disable=SC2016,SC2086 # $PATH is for the new shell; $shell is split on purpose. + p="$(fresh $shell 'printf "%s\n" "$PATH"' 2> /dev/null || true)" + echo "$shell: $p" + case ":$p:" in *":$dir:"*) ;; *) exit 1 ;; esac + done + test-install-script-windows: name: test install.ps1 (Windows) runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 98137df8..d35ff353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `install.sh` puts `ant` on `PATH`, as `install.ps1` already did on Windows, so `ant --version` + works in a new terminal straight after installing (#194). When `INSTALL_DIR` is not already on + `PATH` it adds a guarded line to `~/.profile`, to the user's existing bash startup files, to + `~/.zshenv` when zsh is installed and to fish's `conf.d` when fish is, and lists each file it + changed. A directory whose name contains `$`, a backtick, `"` or `\` is not added, because + shells can run part of such a PATH entry as code (macOS login shells re-read `PATH` through + `path_helper` without escaping it). The installer says so and prints an escaped line for the + current shell instead. A name with `:`, which separates `PATH` entries, can't be added at all, + and an install directory containing a newline (in `INSTALL_DIR`, or in the current directory + for a relative one) is rejected before anything is installed. + `--no-modify-path` or `ANT_NO_MODIFY_PATH=1` (also honoured by `install.ps1`) restores + the old print-the-line behaviour. The macOS default install directory moves from + `/usr/local/bin`, which is root-owned without Homebrew so the install failed without `sudo`, to + `~/.local/bin`, matching Linux. If another `ant` is already on `PATH` — an earlier macOS + install in `/usr/local/bin`, or an unrelated program such as Apache Ant — the installer says + so, since it may shadow the new one. + ## [0.3.12] - 2026-10-22 ### Changed (breaking — `ant-core` browser API) diff --git a/README.md b/README.md index 2ade88cd..603e8604 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,15 @@ Both installers take the same environment variables: |----------|-------------| | `ANT_CHANNEL` | Release channel: `stable` (default) or `beta`. See [Beta Programme](#beta-programme). | | `ANT_VERSION` | Install one specific version, e.g. `0.3.3`. Overrides `ANT_CHANNEL`. | -| `INSTALL_DIR` | Where to put the binary. Defaults to `~/.local/bin` on Linux, `/usr/local/bin` on macOS, `%LOCALAPPDATA%\ant\bin` on Windows. | +| `INSTALL_DIR` | Where to put the binary. Defaults to `~/.local/bin` on Linux and macOS, `%LOCALAPPDATA%\ant\bin` on Windows. | +| `ANT_NO_MODIFY_PATH` | Set to `1` to stop the installer adding `INSTALL_DIR` to your `PATH`. | + +If `INSTALL_DIR` is not already on your `PATH`, the installer adds it, so `ant` works in every new +terminal. `install.ps1` adds it to your Windows user `PATH`. `install.sh` adds one guarded line to +`~/.profile`, to whichever of `~/.bash_profile`, `~/.bash_login` and `~/.bashrc` you already have, +to `~/.zshenv` when zsh is installed and to `conf.d/ant.fish` when fish is, and lists each file it +changed. To leave your startup files alone, pass `--no-modify-path` (`curl ... | bash -s -- +--no-modify-path`) or set `ANT_NO_MODIFY_PATH=1`. ### npm diff --git a/install.ps1 b/install.ps1 index d837fe3a..06b30ac6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,6 +7,8 @@ # ANT_CHANNEL - release channel to install from: "stable" (default) or "beta". # ANT_VERSION - install a specific version (e.g. "0.1.1"). Overrides ANT_CHANNEL. # INSTALL_DIR - override install directory (default: %LOCALAPPDATA%\ant\bin). +# ANT_NO_MODIFY_PATH - set to 1 to leave your user PATH alone; the installer prints the +# directory to add instead. # # Examples: # irm | iex # newest stable @@ -199,12 +201,19 @@ try { # Add to user PATH if not already there $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $ModifyPath = (-not $env:ANT_NO_MODIFY_PATH) -or ($env:ANT_NO_MODIFY_PATH -eq "0") if ($UserPath -notlike "*$InstallDir*") { Say "" - Say "Adding $InstallDir to your user PATH..." - [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User") - $env:Path = "$InstallDir;$env:Path" - Say "Restart your terminal for the PATH change to take effect." + if ($ModifyPath) { + Say "Adding $InstallDir to your user PATH..." + [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User") + $env:Path = "$InstallDir;$env:Path" + Say "Restart your terminal for the PATH change to take effect." + } else { + Say "WARNING: $InstallDir is not in your user PATH." + Say "Add it for this session with:" + Say " `$env:Path = `"$InstallDir;`$env:Path`"" + } } } finally { Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/install.sh b/install.sh index 8da2f202..50ba66ea 100755 --- a/install.sh +++ b/install.sh @@ -5,14 +5,23 @@ # curl -fsSL https://raw.githubusercontent.com/WithAutonomi/ant-client/main/install.sh | bash # # Environment variables: -# ANT_CHANNEL — release channel to install from: "stable" (default) or "beta". -# ANT_VERSION — install a specific version (e.g. "0.1.1"). Overrides ANT_CHANNEL. -# INSTALL_DIR — override install directory (default: ~/.local/bin on Linux, /usr/local/bin on macOS). +# ANT_CHANNEL — release channel to install from: "stable" (default) or "beta". +# ANT_VERSION — install a specific version (e.g. "0.1.1"). Overrides ANT_CHANNEL. +# INSTALL_DIR — override install directory (default: ~/.local/bin). +# ANT_NO_MODIFY_PATH — set to 1 to leave your shell startup files alone (same as --no-modify-path). +# +# Options: +# --no-modify-path — do not add INSTALL_DIR to PATH; print the line to add instead. +# +# When INSTALL_DIR is not already on PATH, the installer adds it to your shell startup files +# (~/.profile, your existing bash files, ~/.zshenv, fish's conf.d) so that `ant` works in every new +# terminal, and prints each file it changed. install.ps1 does the same with the Windows user PATH. # # Examples: -# curl -fsSL | bash # newest stable -# curl -fsSL | ANT_CHANNEL=beta bash # newest beta-eligible -# curl -fsSL | ANT_VERSION=0.3.3 bash # a specific version +# curl -fsSL | bash # newest stable +# curl -fsSL | ANT_CHANNEL=beta bash # newest beta-eligible +# curl -fsSL | ANT_VERSION=0.3.3 bash # a specific version +# curl -fsSL | bash -s -- --no-modify-path # leave PATH to me set -euo pipefail @@ -66,16 +75,161 @@ config_dir() { esac } +# User-writable on both platforms, so the install never needs sudo. macOS used to default to +# /usr/local/bin, which is root-owned on a machine without Homebrew. default_install_dir() { - local os - os="$(uname -s)" - case "$os" in - Linux) echo "$HOME/.local/bin" ;; - Darwin) echo "/usr/local/bin" ;; - *) err "unsupported OS: $os" ;; + echo "$HOME/.local/bin" +} + +# $1 escaped to read back literally inside double quotes in a POSIX shell, for the `export PATH=` +# lines printed for the user to paste. sed rather than ${1//...}, whose replacement quoting differs +# between bash 3.2 and 5.2. +sh_quoted() { printf '%s\n' "$1" | sed 's/[\\"$`]/\\&/g'; } + +# Whether $1 is unsafe to put on PATH from a startup file: shells can end up running part of a name +# containing $, `, " or \ as code, however the line is quoted. macOS login shells, for one, re-read +# PATH through `eval $(path_helper -s)`, and path_helper does not escape it. (A newline is rejected +# before anything is installed.) +unsafe_on_path() { + case "$1" in + *'$'* | *'`'* | *'"'* | *\\*) return 0 ;; + *) return 1 ;; esac } +# PATH is colon-separated, so a directory with `:` in its name can't be one of its entries: a +# trailing `:` adds an empty entry, which searches the current directory, and one in the middle +# adds a second, different directory. +has_colon() { + case "$1" in + *:*) return 0 ;; + *) return 1 ;; + esac +} + +say_colon_refusal() { + say "" + say "WARNING: ${INSTALL_DIR} can't be added to your PATH: ':' in its name separates PATH entries." + say "Install to a directory without it, or run ${INSTALL_DIR}/${BINARY_NAME} by its full path." +} + +# $1 with a leading $HOME spelled as a literal `$HOME`, for lines written into startup files. +home_relative() { + case "$1" in + "$HOME"/*) printf '%s\n' "\$HOME${1#"$HOME"}" ;; + *) printf '%s\n' "$1" ;; + esac +} + +# Shell startup files that should put INSTALL_DIR on PATH, one per line. Mirrors rustup: +# ~/.profile — every POSIX login shell; always written. +# ~/.bash_profile, +# ~/.bash_login — bash login shells skip ~/.profile when either exists; written if present. +# ~/.bashrc — interactive non-login bash (a new Linux terminal); written if present, or +# created when bash is the login shell. +# ~/.zshenv — read by every zsh, interactive or not; written whenever zsh is installed, +# which includes every Mac. +# fish is handled separately, as its syntax differs. +posix_startup_files() { + local shell_name="${SHELL##*/}" rc + + printf '%s\n' "$HOME/.profile" + for rc in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.bashrc"; do + if [ -f "$rc" ]; then + printf '%s\n' "$rc" + elif [ "$rc" = "$HOME/.bashrc" ] && [ "$shell_name" = "bash" ]; then + printf '%s\n' "$rc" + fi + done + if [ "$shell_name" = "zsh" ] || command -v zsh > /dev/null 2>&1; then + printf '%s\n' "${ZDOTDIR:-$HOME}/.zshenv" + fi +} + +# Append line $2 to startup file $1, under a comment saying where it came from. +# Returns 0 when the file was changed, 1 when the line was already there, 2 when the write failed. +append_startup_line() { + local rc="$1" line="$2" gap="" + + if [ -f "$rc" ] && grep -qxF "$line" "$rc"; then + return 1 + fi + if [ -s "$rc" ]; then + gap=$'\n' + fi + { + mkdir -p "$(dirname "$rc")" \ + && printf '%s# Added by the ant installer (https://github.com/%s)\n%s\n' "$gap" "$REPO" "$line" >> "$rc" + } 2> /dev/null || return 2 +} + +# Put INSTALL_DIR on PATH for future shells, and tell the user what changed. +add_install_dir_to_path() { + local dir posix_line fish_line rc i status + local entries=() changed=() present=() failed=() + + if has_colon "$INSTALL_DIR"; then + say_colon_refusal + return + fi + if unsafe_on_path "$INSTALL_DIR"; then + say "" + say "WARNING: ${INSTALL_DIR} was not added to your shell startup files." + # shellcheck disable=SC2016 # The characters are listed, not expanded. + say 'Its name contains $, `, " or \, which some shells would run as code: macOS login shells,' + say "for one, re-read PATH without escaping it. Install to a directory without them, or to use" + say "ant in the current shell only, run:" + say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" + return + fi + + dir="$(home_relative "$INSTALL_DIR")" + # Guarded, so a shell that reads several of these files (Ubuntu's ~/.profile sources ~/.bashrc) + # still ends up with one entry. unsafe_on_path has ruled out everything that is special inside + # double quotes, in POSIX shells and in fish. + # shellcheck disable=SC2016 # $PATH is meant to reach the startup file unexpanded. + posix_line="$(printf 'case ":${PATH}:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac' "$dir" "$dir")" + # shellcheck disable=SC2016 + fish_line="$(printf 'contains "%s" $PATH; or set -gx PATH "%s" $PATH' "$dir" "$dir")" + + # Pairs of (startup file, line to add to it). + while IFS= read -r rc; do + entries+=("$rc" "$posix_line") + done < <(posix_startup_files) + if command -v fish > /dev/null 2>&1; then + entries+=("${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/ant.fish" "$fish_line") + fi + + for ((i = 0; i < ${#entries[@]}; i += 2)); do + rc="${entries[i]}" + status=0 + append_startup_line "$rc" "${entries[i + 1]}" || status=$? + case "$status" in + 0) changed+=("$rc") ;; + 1) present+=("$rc") ;; + *) failed+=("$rc") ;; + esac + done + + say "" + if [ "${#changed[@]}" -gt 0 ]; then + say "Added ${INSTALL_DIR} to your PATH in:" + for rc in "${changed[@]}"; do say " $rc"; done + elif [ "${#present[@]}" -gt 0 ]; then + say "Your shell startup files already add ${INSTALL_DIR} to your PATH." + fi + if [ "${#failed[@]}" -gt 0 ]; then + say "Could not write to:" + for rc in "${failed[@]}"; do say " $rc"; done + fi + if [ "${#changed[@]}" -eq 0 ] && [ "${#present[@]}" -eq 0 ]; then + say "Add it to your PATH with:" + else + say "Open a new terminal to use ant, or run this to use it in the current one:" + fi + say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" +} + # Whether a version is eligible for a release channel. # # Deliberately mirrors `version_matches_channel` in ant-core/src/channel.rs, which in turn mirrors @@ -184,10 +338,32 @@ resolve_version() { need curl need tar +MODIFY_PATH=1 +case "${ANT_NO_MODIFY_PATH:-}" in + ""|0) ;; + *) MODIFY_PATH=0 ;; +esac +for arg in "$@"; do + case "$arg" in + --no-modify-path) MODIFY_PATH=0 ;; + *) err "unknown option '$arg' (the only option is --no-modify-path)" ;; + esac +done + TARGET="$(detect_target)" CHANNEL="${ANT_CHANNEL:-stable}" VERSION="${ANT_VERSION:-$(resolve_version "$CHANNEL")}" INSTALL_DIR="${INSTALL_DIR:-$(default_install_dir)}" +# Checked before anything is downloaded or created, on the path as it will be used: a relative +# INSTALL_DIR is resolved against the current directory, whose own name could hold the newline. +# No PATH line can hold one. ($PWD, unlike `$(pwd)`, keeps a trailing newline.) +case "$INSTALL_DIR" in + /*) effective_install_dir="$INSTALL_DIR" ;; + *) effective_install_dir="$PWD/$INSTALL_DIR" ;; +esac +case "$effective_install_dir" in + *$'\n'*) err "the install directory must not contain a newline (in INSTALL_DIR, or in the current directory for a relative one)" ;; +esac if [ -n "${ANT_VERSION:-}" ]; then say "Installing ant ${VERSION} for ${TARGET}..." @@ -208,7 +384,12 @@ say "Extracting..." tar xzf "${TMPDIR}/${ARCHIVE}" -C "$TMPDIR" # Install binary -mkdir -p "$INSTALL_DIR" +mkdir -p -- "$INSTALL_DIR" +# Absolute, so the PATH entry written below does not depend on where the installer was run from. +# CDPATH cleared, or `cd` into a relative directory found through it prints that directory too. +# The `x` stops `$(...)` stripping newlines from the end of the path; only pwd's own is cut off. +INSTALL_DIR="$(CDPATH='' cd -- "$INSTALL_DIR" && pwd && printf x)" +INSTALL_DIR="${INSTALL_DIR%$'\n'x}" cp "${TMPDIR}/${BINARY_NAME}-${VERSION}-${TARGET}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" chmod +x "${INSTALL_DIR}/${BINARY_NAME}" say "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" @@ -227,12 +408,29 @@ fi case ":$PATH:" in *":${INSTALL_DIR}:"*) ;; *) - say "" - say "WARNING: ${INSTALL_DIR} is not in your PATH." - say "Add it with:" - say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + if [ "$MODIFY_PATH" = 1 ]; then + add_install_dir_to_path + elif has_colon "$INSTALL_DIR"; then + say_colon_refusal + else + say "" + say "WARNING: ${INSTALL_DIR} is not in your PATH." + say "Add it with:" + say " export PATH=\"$(sh_quoted "$INSTALL_DIR"):\$PATH\"" + fi ;; esac +# Another `ant` can shadow this one: an older install (macOS used to default to /usr/local/bin), or +# an unrelated program such as Apache Ant. macOS login shells put /usr/local/bin and Homebrew's bin +# ahead of ~/.local/bin, so either wins there. +OTHER_ANT="$(command -v "$BINARY_NAME" 2> /dev/null || true)" +if [ -n "$OTHER_ANT" ] && [ "$OTHER_ANT" != "${INSTALL_DIR}/${BINARY_NAME}" ]; then + say "" + say "NOTE: ${OTHER_ANT} is also on your PATH and may run instead of this ant." + say "If it is an older install of this ant, remove it. If it is another program (Apache Ant is" + say "also called ant), put ${INSTALL_DIR} first on your PATH or run ${INSTALL_DIR}/${BINARY_NAME}." +fi + say "" say "Done! Run 'ant --help' to get started."