From 00bc602f9343abbae11e609766e47e6662882fdf Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 16:07:56 -0500 Subject: [PATCH] fix(installer): persist the bootstrapped Node on PATH, and stop duplicating PATH lines Two defects found by the second unattended greenfield run on canary (VMID 1125, rolled back to its greenfield snapshot first). 1. ensure_node() exported the Mosaic-managed Node for the installer process and nothing wrote it down. The install finished rc=0, put $PREFIX/bin in ~/.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() now writes the runtime's bin dir to the same profile, from both the fresh-install and the already-installed-but-not-on-PATH branches. 2. The 'is it already in a shell rc file' guard was a single `grep -qslF "$dir" "${rc_files[@]}"` over four paths, most of which do not exist on a clean host. Handing grep a missing file makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q already 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 another PATH line, so every re-install grew the profile. Measured: 3 runs produced 3 duplicate entries; with the fix, 1. path_entry_exists() now tests each file for existence and greps it on its own, so the result does not depend on the grep implementation. The profile-writing body is factored into persist_on_path(), shared by the CLI prefix and the Node runtime, since both now need identical treatment. Verified in a scratch $HOME: fresh write, idempotent across three runs, zsh routes to .zshenv, an unwritable profile warns and survives set -e, and an already-on-PATH prefix is a no-op that creates no file. Falsified by restoring the multi-file grep: duplicates return. --- tools/install.sh | 90 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 23 deletions(-) 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 ────────────────────────────────────────────────────────────────