Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 93 additions & 8 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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