diff --git a/tools/install.sh b/tools/install.sh index d6db3f28..f6a29833 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -309,52 +309,84 @@ require_cmd() { fi } -# Persist $PREFIX/bin on PATH instead of only warning about it. +# True if any shell rc file already puts $1 on PATH. # -# The warning it replaces was the last step of an otherwise successful install, -# so the installer reported success and left `mosaic: command not found` — an -# unattended install had no operator to read the advice and act on it. +# Each file is tested for existence first and grepped one at a time, rather than +# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file +# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q +# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless. +# On the 2 path the caller reads "not present yet" and appends a duplicate PATH +# line on every single install. +path_entry_exists() { + local dir="$1" rc_file + for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do + if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then + return 0 + fi + done + return 1 +} + +# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this +# process. An `export` here reaches only the installer; every directory the +# install leaves behind has to be written down somewhere a later shell reads. # # Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for # non-interactive shells, so a PATH line appended to the bottom of it is # unreachable to `bash -lc`, to systemd units, and to every agent seat — the -# exact consumers that need the CLI. ~/.profile is read by login shells and -# Debian's .profile sources .bashrc for interactive ones, so a single line there -# reaches both. For zsh the always-sourced file is .zshenv, not .zshrc. -ensure_prefix_on_path() { - if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then - return +# exact consumers that need these binaries. ~/.profile is read by login shells +# and Debian's .profile sources .bashrc for interactive ones, so a single line +# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc. +# +# $1 = directory to add, $2 = label for the comment line. +# Returns 1 (having warned) if the profile could not be written. +persist_on_path() { + local dir="$1" label="$2" profile + + if path_entry_exists "$dir"; then + return 0 fi - local profile rc_files=("$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc") if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then profile="$HOME/.zshenv" else profile="$HOME/.profile" fi - if grep -qslF "$PREFIX/bin" "${rc_files[@]}" 2>/dev/null; then - warn "$PREFIX/bin is in your shell profile but not in this shell" - dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" - return - fi - # Probe writability in a subshell. A redirection failure on a special built-in # aborts the shell it runs in, so it has to be a child; and the redirection on # the subshell is what silences the "Permission denied" the shell would # otherwise print ahead of our own message. if ! ( : >>"$profile" ) 2>/dev/null; then - warn "$PREFIX/bin is not on your PATH and $profile could not be written" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - return + warn "$dir is not on your PATH and $profile could not be written" + dim " Add to your shell rc: export PATH=\"$dir:\$PATH\"" + return 1 fi { echo "" - echo "# Mosaic CLI" - echo "export PATH=\"$PREFIX/bin:\$PATH\"" + echo "# $label" + echo "export PATH=\"$dir:\$PATH\"" } >>"$profile" - ok "Added $PREFIX/bin to PATH in $profile" + ok "Added $dir to PATH in $profile" + return 0 +} + +# Persist $PREFIX/bin on PATH instead of only warning about it. +# +# The warning it replaces was the last step of an otherwise successful install, +# so the installer reported success and left `mosaic: command not found` — an +# unattended install had no operator to read the advice and act on it. +ensure_prefix_on_path() { + if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then + return + fi + + if path_entry_exists "$PREFIX/bin"; then + warn "$PREFIX/bin is in your shell profile but not in this shell" + elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then + return + fi dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" } @@ -670,6 +702,16 @@ install_node() { return 0 } +# Make the Mosaic-managed Node reachable from the next shell as well as this +# one. Measured on a greenfield canary run: without this the install finished +# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic` +# and then died on `env: 'node': No such file or directory` — the CLI is a Node +# script, so a CLI on PATH without its runtime is a successful install that +# produces a broken command. +persist_node_on_path() { + persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true +} + ensure_node() { if command -v node &>/dev/null && node_is_suitable node; then return 0 @@ -678,6 +720,7 @@ ensure_node() { # A previous run may have installed one that is not on this shell's PATH. if node_is_suitable "$NODE_ROOT/current/bin/node"; then export PATH="$NODE_ROOT/current/bin:$PATH" + persist_node_on_path return 0 fi @@ -705,6 +748,7 @@ ensure_node() { fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run." exit 1 fi + persist_node_on_path } # ─── preflight ────────────────────────────────────────────────────────────────