diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5887242..995427d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,8 +239,10 @@ jobs: - uses: actions/checkout@v4 # INSTALL_DIR inside the runner's temp, so there is no sudo prompt and - # nothing to clean up. The default path is /usr/local/bin and that is - # what the sudo fallback is for; this checks the installer, not sudo. + # nothing to clean up. The default path is now the writable per-user + # $HOME/.local/bin (no sudo required); this checks the installer, not + # sudo. The sudo fallback is for a custom INSTALL_DIR that is not + # writable, as the /opt test below exercises. # /bin/bash explicitly, and the versions on the log, so which shell this # ran under is a fact and not an assumption. macOS ships bash 3.2.57 - # frozen in 2007 by its licence - where several things that work @@ -268,9 +270,73 @@ jobs: run: | set -euo pipefail /bin/bash scripts/install.sh + # The default install dir is $HOME/.local/bin; verify the binary exists + # there and runs. $HOME/.local/bin is not on PATH yet — a fresh step + # does not source the user's profile — so we still need the file test. + test -x "$HOME/.local/bin/nan" + "$HOME/.local/bin/nan" --version + # Append $HOME/.local/bin to $GITHUB_PATH so later steps inherit it, + # mirroring what a fresh terminal would get from the profile the + # installer wrote. + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # The default install is on PATH in a fresh shell. + - name: The default install is on PATH in a fresh shell + run: | + set -euo pipefail command -v nan nan --version + # The PATH logic is the part of this installer that runs after the + # binary is in place, and the part that was wrong: it wrote ~/.profile, + # which zsh never reads and bash skips when ~/.bash_profile exists. This + # exercises it with no download at all, by sourcing the installer and + # calling the functions directly. + - name: The PATH line lands in the right shell profile + run: | + set -euo pipefail + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + # shell_profile() has to name the file that shell actually reads. + test "$(HOME="$tmp/h1" SHELL=/bin/zsh bash -c 'source scripts/install.sh; shell_profile')" = "$tmp/h1/.zshrc" + test "$(HOME="$tmp/h2" SHELL=/bin/bash bash -c 'source scripts/install.sh; shell_profile')" = "$tmp/h2/.bashrc" + mkdir -p "$tmp/h3"; touch "$tmp/h3/.bash_profile" + test "$(HOME="$tmp/h3" SHELL=/bin/bash bash -c 'source scripts/install.sh; shell_profile')" = "$tmp/h3/.bash_profile" + test "$(HOME="$tmp/h4" SHELL=/usr/bin/fish bash -c 'source scripts/install.sh; shell_profile')" = "$tmp/h4/.config/fish/config.fish" + test "$(HOME="$tmp/h5" SHELL=/bin/tcsh bash -c 'source scripts/install.sh; shell_profile')" = "$tmp/h5/.profile" + + # A default install prepends the directory - so a nan already earlier + # in PATH cannot keep winning - and running it twice changes nothing. + home="$tmp/default"; mkdir -p "$home" + HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin bash -c 'source scripts/install.sh; check_path "$HOME/.local/bin"' >/dev/null 2>&1 + grep -qF "export PATH=\"$home/.local/bin:\$PATH\"" "$home/.zshrc" + HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin bash -c 'source scripts/install.sh; check_path "$HOME/.local/bin"' >/dev/null 2>&1 + test "$(grep -cF "$home/.local/bin" "$home/.zshrc")" = 1 + + # fish gets fish syntax, not an export. + home="$tmp/fish"; mkdir -p "$home" + HOME="$home" SHELL=/usr/bin/fish PATH=/usr/bin:/bin bash -c 'source scripts/install.sh; check_path "$HOME/.local/bin"' >/dev/null 2>&1 + grep -qF "fish_add_path \"$home/.local/bin\"" "$home/.config/fish/config.fish" + + # A custom INSTALL_DIR is a deliberate choice: never edit a profile. + home="$tmp/custom"; mkdir -p "$home" + HOME="$home" SHELL=/bin/zsh PATH=/usr/bin:/bin bash -c 'source scripts/install.sh; check_path "$HOME/elsewhere"' >/dev/null 2>&1 + test ! -e "$home/.zshrc" + test ! -e "$home/.profile" + + # A nan earlier in PATH is what turns a fixed installer into a bug + # report, so it has to be called out. + home="$tmp/shadow"; mkdir -p "$home/shadow" + printf '#!/bin/sh\n' > "$home/shadow/nan"; chmod +x "$home/shadow/nan" + out="$(bash -c "source scripts/install.sh; warn_if_shadowed \"$home/shadow/nan\" \"$home/.local/bin\"" 2>&1)" + grep -qF "$home/shadow/nan" <<<"$out" + grep -qi shadow <<<"$out" + out="$(bash -c "source scripts/install.sh; warn_if_shadowed \"$home/.local/bin/nan\" \"$home/.local/bin\"" 2>&1)" + test -z "$out" + + echo "PATH logic verified for zsh, bash, fish and a custom dir" + # The version lookup failing has to SAY so. It did not: the pipeline runs # under `set -euo pipefail`, so a grep finding no tag_name - which is the # rate-limited case - killed the script one line before the message that @@ -300,7 +366,7 @@ jobs: # And the branch nobody had ever taken: a destination the user cannot # write to, where install_bin falls back to sudo. On a runner both - # /usr/local/bin and the temp dir are writable, so the fallback would + # $HOME/.local/bin and the temp dir are writable, so the fallback would # never fire on its own and "it installs fine" would say nothing about # it. A root-owned directory forces it. - name: Force the sudo fallback diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11d0e46..724d9de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,10 +25,16 @@ To install a released binary instead of building from source: curl -fsSL https://nan.builders/install | bash ``` -By default it installs to `/usr/local/bin`. Override with `INSTALL_DIR`: +By default it installs to `$HOME/.local/bin`, which is per-user and needs no root. Override with `INSTALL_DIR`: ```bash -INSTALL_DIR=~/.local/bin curl -fsSL https://nan.builders/install | bash +INSTALL_DIR=~/.nan/bin curl -fsSL https://nan.builders/install | bash +``` + +A custom `INSTALL_DIR` never edits a shell profile: if the directory is not on your `PATH`, the installer prints the line to add yourself. For a machine-wide install, with root or sudo: + +```bash +curl -fsSL https://nan.builders/install | sudo INSTALL_DIR=/usr/local/bin bash ``` ## Project structure diff --git a/README.md b/README.md index e695b6a..95689d4 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,22 @@ CLI for [nan.builders](https://nan.builders) — manage your account, monitor us curl -fsSL https://nan.builders/install | bash ``` -By default installs to `/usr/local/bin`. Override with `INSTALL_DIR`: +By default installs to `$HOME/.local/bin`, which is per-user and needs no root. Override with `INSTALL_DIR`: ```bash -INSTALL_DIR=~/.local/bin curl -fsSL https://nan.builders/install | bash +INSTALL_DIR=~/.nan/bin curl -fsSL https://nan.builders/install | bash ``` +If `$HOME/.local/bin` is not already on your `PATH`, the installer appends it to the startup file your shell actually reads — `~/.zshrc` for zsh, `~/.bashrc` or `~/.bash_profile` for bash, `config.fish` for fish — and tells you to source that file or open a new terminal. + +A custom `INSTALL_DIR` is a deliberate choice, so the installer never edits a shell profile for it; if the directory is not on your `PATH`, it prints the line to add yourself. To make `nan` available to every user on the machine, pass the system-wide directory (needs root or sudo): + +```bash +curl -fsSL https://nan.builders/install | sudo INSTALL_DIR=/usr/local/bin bash +``` + +`/usr/local/bin` is already on root's `PATH`, so nothing else is needed. + On Windows, from PowerShell: ```powershell diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 24e8430..e2fb14c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -13,10 +13,10 @@ - The artifact is a .zip, not a .tar.gz. Nothing on a stock Windows unpacks a tarball by double-clicking, and Expand-Archive is built in. - The default install directory is per-user (LOCALAPPDATA\Programs\nan) and - not a machine-wide one. /usr/local/bin has a sudo prompt that a person - expects; the Windows equivalent is an elevation dialog out of a piped - script, which is worse than installing for one user. Set -InstallDir to - override. + not a machine-wide one. $HOME/.local/bin is per-user and needs no elevation; + the old convention of a machine-wide directory like /usr/local/bin would + require a sudo prompt, which is worse for a piped script than installing + for one user. Set -InstallDir to override. .PARAMETER Version A tag such as v0.1.3. Defaults to the latest release. diff --git a/scripts/install.sh b/scripts/install.sh index efdd2e1..cd6ed04 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,11 @@ set -euo pipefail # Overridable so a fork can install its own build, and so the failure paths # below can be exercised against a repo that is not there. REPO="${REPO:-helmcode/nan-cli}" -INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +# The directory the one-liner uses. Kept separate from INSTALL_DIR because +# check_path only edits a shell profile for this default: a custom INSTALL_DIR +# is a deliberate choice and must not come with a silent profile edit. +DEFAULT_INSTALL_DIR="$HOME/.local/bin" +INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" VERSION="${VERSION:-}" if [ -t 1 ]; then @@ -137,6 +141,14 @@ verify_provenance() { return 0 fi + # "unknown command" or similar means gh is too old for attestation — skip + case "$out" in + *"unknown command"*|*"command not found"*|*"subcommand not found"*) + info "gh does not support attestation (too old), skipping provenance check" + return 0 + ;; + esac + # Nothing recorded against these bytes, which is a 404 from the attestations # API. Two different things look identical from out here: a release from # before this repo signed anything, and an archive that is not the one it @@ -180,20 +192,88 @@ install_bin() { sudo install -d "$dest_dir" && sudo install -m 755 "$src" "$dest_dir/nan" } +shell_profile() { + local shell_name + shell_name="$(basename "${SHELL:-}" 2>/dev/null || echo "")" + case "$shell_name" in + zsh) + echo "$HOME/.zshrc" + ;; + bash) + if [ -f "$HOME/.bashrc" ]; then + echo "$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + echo "$HOME/.bash_profile" + else + echo "$HOME/.bashrc" + fi + ;; + fish) + echo "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" + ;; + *) + echo "$HOME/.profile" + ;; + esac +} + check_path() { local dir="$1" case ":$PATH:" in - *":$dir:"*) ;; - *) - warn "$dir is not in your PATH" - warn "add this to your shell profile:" - printf " export PATH=\"%s:\$PATH\"\n" "$dir" + *":$dir:"*) + info "$dir is already in PATH" + return ;; esac + + if [ "$dir" != "$DEFAULT_INSTALL_DIR" ]; then + warn "$dir is not in PATH. To add it, append this line to your shell profile:" + printf " export PATH=\"%s:\$PATH\"\n" "$dir" + warn "the installer did not modify any profile for a custom INSTALL_DIR" + return + fi + + local profile line + profile="$(shell_profile)" + line="fish_add_path \"$dir\"" + if [ "$(basename "${SHELL:-}" 2>/dev/null || echo "")" != "fish" ]; then + line="export PATH=\"$dir:\$PATH\"" + fi + if grep -qF "$dir" "$profile" 2>/dev/null; then + info "$dir is already in $profile" + return + fi + local profile_dir + profile_dir="$(dirname "$profile")" + if [ ! -d "$profile_dir" ]; then + mkdir -p "$profile_dir" 2>/dev/null || true + fi + printf '\n# Added by nan-cli installer\n%s\n' "$line" >> "$profile" + log "added $dir to PATH in $profile" + warn "this shell was not changed; run 'source $profile' or open a new terminal" +} + +warn_if_shadowed() { + local found="$1" install_dir="$2" + if [ -z "$found" ]; then + return + fi + local found_dir + found_dir="$(cd "$(dirname "$found")" 2>/dev/null && pwd)" || found_dir="" + local install_dir_resolved + install_dir_resolved="$(cd "$install_dir" 2>/dev/null && pwd)" || install_dir_resolved="" + if [ "$found_dir" = "$install_dir_resolved" ]; then + return + fi + warn "another nan at $found is earlier in PATH and will run until PATH is fixed" + if [ "$found" = "/usr/local/bin/nan" ]; then + warn "remove the old one: sudo rm /usr/local/bin/nan" + fi + warn "opening a new terminal will put $install_dir first once your profile is sourced" } main() { - local os arch version archive url checksums_url expected_checksum + local os arch version archive url checksums_url expected_checksum existing_nan os="$(detect_os)" arch="$(detect_arch)" @@ -227,11 +307,16 @@ main() { tar xz -C "$tmpdir" -f "$tmpdir/$archive" + existing_nan="$(type -P nan 2>/dev/null || true)" + info "installing to ${INSTALL_DIR}/nan..." install_bin "$tmpdir/nan" "$INSTALL_DIR" log "installed ${BOLD}${version}${RESET} to ${INSTALL_DIR}/nan" + warn_if_shadowed "$existing_nan" "$INSTALL_DIR" check_path "$INSTALL_DIR" } -main "$@" +if [ -z "${BASH_SOURCE[0]:-}" ] || [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi