diff --git a/CHANGELOG.md b/CHANGELOG.md index bb56df2..07098db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Versioning complies with [semantic versioning (semver)](http://semver.org/). +* **[v0.4.0](https://github.com/mklement0/n-install/compare/v0.3.7...v0.4.0)** (2017-10-26): + * [enhancement] The integrity of helper scripts `n-update` and `n-uninstall`, which are downloaded by `n-install` from + this repo, is now verified via SHA-256 checksums embedded in `n-install`. + * **[v0.3.7](https://github.com/mklement0/n-install/compare/v0.3.6...v0.3.7)** (2017-10-25): * [doc] Clarified that even during local execution after having manually downloaded `n-install` helper scripts are downloaded from this repo. diff --git a/Makefile b/Makefile index 36cf27c..2f0f67d 100644 --- a/Makefile +++ b/Makefile @@ -121,7 +121,14 @@ version: [[ `json -f package.json version` == "$$newVer" ]] || { npm version $$newVer --no-git-tag-version >/dev/null && printf $$'\e[0;33m%s\e[0m\n' 'package.json' || exit; }; \ [[ $$gitTagVer == '(none)' ]] && newVerMdSnippet="**v$$newVer**" || newVerMdSnippet="**[v$$newVer](`json -f package.json repository.url | sed 's/.git$$//'`/compare/v$$gitTagVer...v$$newVer)**"; \ grep -Eq "\bv$${newVer//./\.}[^[:digit:]-]" CHANGELOG.md || { { sed -n '1,/^ +* **[v0.4.0](https://github.com/mklement0/n-install/compare/v0.3.7...v0.4.0)** (2017-10-26): + * [enhancement] The integrity of helper scripts `n-update` and `n-uninstall`, which are downloaded by `n-install` from + this repo, is now verified via SHA-256 checksums embedded in `n-install`. + * **[v0.3.7](https://github.com/mklement0/n-install/compare/v0.3.6...v0.3.7)** (2017-10-25): * [doc] Clarified that even during local execution after having manually downloaded `n-install` helper scripts are downloaded from this repo. diff --git a/bin/n-install b/bin/n-install index 221a0e3..9c7691e 100755 --- a/bin/n-install +++ b/bin/n-install @@ -28,7 +28,7 @@ kN_REPO_URL='https://github.com/tj/n' # n's GitHub repo URL kN_DIRNAME='n' # The name of the subdir. of the $kPREFIX_DIR that *n itself assumes* it is installed in. kSUBDIRS=( "$kN_DIRNAME" bin include lib share ) # (informational) all subdirs. of N_PREFIX into which files will be installed as of node 0.12 -## Names and download URLs for the helper scripts +## Names, download URLs, and checksums for the helper scripts kUPDATE_SCRIPT='n-update' # Filename of the custom update script. kUNINSTALL_SCRIPT='n-uninstall' # Filename of the custom uninstall script. kHELPER_SCRIPTS=( @@ -38,6 +38,19 @@ kHELPER_SCRIPTS=( kHELPER_SCRIPT_URLS=( "${kTHIS_REPO_URL_LONG/\/\/github.com\////raw.githubusercontent.com/}/stable/bin/$kUPDATE_SCRIPT" "${kTHIS_REPO_URL_LONG/\/\/github.com\////raw.githubusercontent.com/}/stable/bin/$kUNINSTALL_SCRIPT" +) + # SHA-256 checksum for the helper scripts. + # !! These checksums must be updated whenever `n-update` and `n-uninstall` + # !! are modified, which also happens when merely the version number is + # !! bumped. + # !! The Makefile takes care of updating after every version bump + # !! (`make version`` or implicitly with `make release``), but you can do it on + # !! demand with `make update-checksums`. + # !! DO NOT MODIFY THE *FORMAT* OF THIS ARRAY LITERAL - `util/update-checksums` + # !! and a test rely on it. +kSHA256_SUMS=( + "2c974ab30eee3aa2d3811b7794a815ad52b181a2abd87b01678af3f9da67ebcc $kUPDATE_SCRIPT" + "638b4deb9e2af72e2f737dc7b53f11bbdab8240e61c36b6b68e0c95560d08e44 $kUNINSTALL_SCRIPT" ) ## @@ -419,7 +432,7 @@ unset CDPATH # to prevent unpredictable `cd` behavior [[ -t 1 ]] || kNO_COLOR=1 # turn off colored output if stdout is not connected to a terminal # Output version number and exit, if requested. Note that the `ver='...'` statement is automatically updated by `make version VER=` - DO keep the 'v' prefix in the variable _definition_. -[[ $1 == '--version' ]] && { ver='v0.3.7'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } +[[ $1 == '--version' ]] && { ver='v0.4.0'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } # !! AS OF n 1.3.0, n ITSELF ONLY WORKS WITH curl, NOT ALSO WITH wget. # !! Once n also supports wget, mention wget as an alternative in the help text. @@ -763,29 +776,57 @@ fi if (( ! helpersCopiedLocally )); then # Running from GitHub with `curl ... | bash`, or from a lone local copy of `n-install` without its helper scripts present. + # Find a SHA-256 checksum utility and construct a verification command. + shaSumVerifyCmd= + [[ -n $(command -v sha256sum) ]] && shaSumVerifyCmd=( 'sha256sum' '-c' '--status' ) # Linux + [[ -z $shaSumVerifyCmd && -n $(command -v shasum) ]] && shaSumVerifyCmd=( 'shasum' '-a' '256' '-c' '--status' ) # macOS + # Download helper scripts from GitHub. + if [[ -z $shaSumVerifyCmd ]]; then # No SHA checksum-verification utility found - this should not happen. - cd "$nBinDir" || die - i=0 - for helperScript in "${kHELPER_SCRIPTS[@]}"; do - helperScriptUrl="${kHELPER_SCRIPT_URLS[i++]}" - # Note: The curl / wget command succeeds even if the target file doesn't exist, so we - # check the resulting file's 1st line for a shebang line to determine - # if a script was truly downloaded or not, and remove a download file - # that's not a script. - [[ -n $(command -v curl) ]] && - downloadCmdArgs=( curl -sS "$helperScriptUrl" -O ) || - downloadCmdArgs=( wget --quiet "$helperScriptUrl" ) - "${downloadCmdArgs[@]}" && head -n 1 "$helperScript" | grep -q '^#!' && chmod +x "$helperScript" || { - rm -f "$helperScript" - warn - </dev/null + } + + # Verify the checksum + if [[ -f "$helperScript" ]]; then + echo "${kSHA256_SUMS[i]}" | "${shaSumVerifyCmd[@]}" || { + rm -f "$helperScript" || die + warn - </dev/null + fi fi diff --git a/bin/n-install copy b/bin/n-install copy new file mode 100755 index 0000000..f284b8a --- /dev/null +++ b/bin/n-install copy @@ -0,0 +1,891 @@ +#!/usr/bin/env bash + +# !! CAVEAT: +# !! On FreeBSD, process substitution (<(...)) is by default NOT ENABLED - AVOID PROCESS SUBSTITUTIONS IN THIS SCRIPT. +# !! (hypothetical, for now, because n as of 1.3.0 doesn't support FreeBSD, given that binary Node.js packages aren't available for it) + +# +# Script-global constants +# + +kMIN_BASH_VERSION='3.2' # due to use of =~, we require at least 3.2 + +##### IMPORTANT: These names must, at least in part, be kept in sync with their counterparts in 'n-update' and 'n-uninstall'. +kINSTALLER_NAME=n-install # This script's name; note that since we'll typically be running via curl directly from GitHub, using $(basename "$BASH_SOURCE") to determine the name is not an option. +kTHIS_REPO_URL='https://git.io/n-install-repo' # This script's source repository - SHORT, git.io-based form. +kTHIS_REPO_URL_LONG='https://github.com/mklement0/n-install' # This script's source repository in LONG form - needed for deriving raw.githubusercontent.com URLs from it. +kPREFIX_DIR=${N_PREFIX:-$HOME/n} # The target prefix directory, inside which both a dir. for n itself and the active node version's dirs. will be located. +## For updating the relevant shell initialization file: The string that identifies the line added by us. +## !! Now that this project has been published, we HARD-CODE this ID string to ensure that old installations are found. +## !! For instance, we've changed from http:// to https:// URLs later, but the ID string must continue to use http:// to avoid breaking later uninstalls / reinstalls. +kINIT_FILE_LINE_ID=" # Added by n-install (see http://git.io/n-install-repo)." +kREGEX_CONFLICTING_LINE='^[^#]*\bN_PREFIX=' # Regex that identifies relevant existing lines. +##### + +kTHIS_NAME=$kINSTALLER_NAME + +kN_REPO_URL='https://github.com/tj/n' # n's GitHub repo URL +kN_DIRNAME='n' # The name of the subdir. of the $kPREFIX_DIR that *n itself assumes* it is installed in. +kSUBDIRS=( "$kN_DIRNAME" bin include lib share ) # (informational) all subdirs. of N_PREFIX into which files will be installed as of node 0.12 + +## Names, download URLs, and checksums for the helper scripts +kUPDATE_SCRIPT='n-update' # Filename of the custom update script. +kUNINSTALL_SCRIPT='n-uninstall' # Filename of the custom uninstall script. +kHELPER_SCRIPTS=( + "$kUPDATE_SCRIPT" + "$kUNINSTALL_SCRIPT" +) +kHELPER_SCRIPT_URLS=( + "${kTHIS_REPO_URL_LONG/\/\/github.com\////raw.githubusercontent.com/}/stable/bin/$kUPDATE_SCRIPT" + "${kTHIS_REPO_URL_LONG/\/\/github.com\////raw.githubusercontent.com/}/stable/bin/$kUNINSTALL_SCRIPT" +) + # SHA-256 checksum for the helper scripts. + # !! DO NOT MODIFY THE *FORMAT* OF THIS ARRAY LITERAL - tests rely on it. + # !! *UPDATE THE CHECKSUMS* WHENEVER `n-update` AND `n-uninstall` ARE MODIFIED. + # !! FOR NOW, THE Makefile DOES NOT AUTO-UPDATE THE CHECKSUMS - only the tests + # !! will catch a mismatch. +kSHA256_SUMS=( + "2c974ab30eee3aa2d3811b7794a815ad52b181a2abd87b01678af3f9da67ebcc $kUPDATE_SCRIPT" + "638b4deb9e2af72e2f737dc7b53f11bbdab8240e61c36b6b68e0c95560d08e44 $kUNINSTALL_SCRIPT" +) +## + +# +# BEGIN: Helper functions +# + +# SYNOPIS +# isMinBashVersion +# DESCRIPTION +# Indicates via exit code whether the Bash version running this script meets the minimum version number specified. +# EXAMPLES +# isMinBashVersion 3.2 +# isMinBashVersion 4 +isMinBashVersion() { + local minMajor minMinor thisMajor thisMinor + IFS=. read -r minMajor minMinor <<<"$1" + [[ -z $minMinor ]] && minMinor=0 + thisMajor=${BASH_VERSINFO[0]} + thisMinor=${BASH_VERSINFO[1]} + (( thisMajor > minMajor || (thisMajor == minMajor && thisMinor >= minMinor) )) +} + +# !! ========== IMPORTANT: +# !! Since Bash's parsing of the script FAILS BELOW on versions < 3.2 due to use of `=~`, +# !! we do the version check HERE, right after definining the function, which should work. +# !! Verified on Bash 3.1 - unclear, how far back it works, however. +isMinBashVersion "$kMIN_BASH_VERSION" || { echo "FATAL ERROR: This script requires Bash $kMIN_BASH_VERSION or higher. You're running: $BASH_VERSION" >&2; exit 1; } +# !! ========== + +# SYNOPSIS +# echoColored colorNum [text...] +# DESCRIPTION +# Prints input in the specified color, which must be an ANSI color code (e.g., 31 for red). +# Input is either provided via the TEXT operands, or, in their absence, from stdin. +# If input is provided via TXT operands, a trailing \n is added. +# NOTE: +# - Unlike echo, uses stdin, if no TEXT arguments are specified; you MUST either specify +# at least one input operand OR stdin input; in that sense, this function is like a hybrid +# between echo and cat. However, *interactive* stdin input makes no sense, and therefore +# a newline is simply printed - as with echo without arguments - if stdin is connected to +# a terminal and neither operands nor stdin input is provided. +# - Coloring is suppressed, if the variable kNO_COLOR exists and is set to 1. +# An invoking script may set this in case output is NOT being sent to a terminal. +# (E.g., test -t 1 || kNO_COLOR=1) +# EXAMPLES +# echoColored 31 "I'm red" +# cat file | echoColored 32 # file contents is printed in green +echoColored() { + local pre="\033[${1}m" post='\033[0m' + (( kNO_COLOR )) && { pre= post=; } + shift # skip the color argument + if (( $# )); then + printf "${pre}%s${post}\n" "$*" + else + [[ -t 0 ]] && { printf '\n'; return; } # no interactive stdin input + printf "$pre"; cat; printf "$post" + fi +} + +# SYNOPSIS +# dieSyntax [msg|-] +# DESCRIPTION +# Prints a red error message to stderr and exits with exit code 2, meant to indicate a +# syntax problem (invalid arguments). +# A standard message is provided, if no arguments are given. +# If the first (and only) argument is '-', input is taken from stdin; otherwise, the +# first argument specifies the message to print. +# Either way, a preamble with this script's name and the type of message is printed. +# NOTES +# Uses echoColored(), whose coloring may be suppressed with kNO_COLOR=1. +dieSyntax() { + local kPREAMBLE="$kTHIS_NAME: ARGUMENT ERROR:" + if [[ $1 == '-' ]]; then # from stdin + { + printf '%s\n' "$kPREAMBLE" + sed 's/^/ &/' + } | echoColored 31 # red + else # from operands + echoColored 31 "$kPREAMBLE: ${1:-"Invalid argument(s) specified."} Use -h for help." + fi + exit 2 +} >&2 + +# SYNOPSIS +# die [msg|- [exitCode]] +# DESCRIPTION +# Prints a red error message to and by default exits with exit code 1, meant to indicate +# a runtime problem. +# A standard message is provided, if no arguments are given. +# If the first (and only) argument is '-', input is taken from stdin; otherwise, the +# first argument specifies the message to print. +# Either way, a preamble with this script's name and the type of message is printed. +# NOTES +# Uses echoColored(), whose coloring may be suppressed with kNO_COLOR=1. +die() { + local kPREAMBLE="$kTHIS_NAME: ERROR:" + if [[ $1 == '-' ]]; then # from stdin + { + printf '%s\n' "$kPREAMBLE" + sed 's/^/ &/' + } | echoColored 31 # red + else # from operands + echoColored 31 "$kPREAMBLE ${1:-"ABORTING due to unexpected error."}" + fi + exit ${2:-1} +} >&2 + +# SYNOPSIS +# warn [msg|-] +# DESCRIPTION +# Prints a yellow warning message to stderr. +# If the first (and only) argument is '-', input is taken from stdin; otherwise, the +# first argument specifies the message to print. +# Either way, a preamble with this script's name and the type of message is printed. +# NOTES +# Uses echoColored(), whose coloring may be suppressed with kNO_COLOR=1. +warn() { + local kPREAMBLE="$kTHIS_NAME: WARNING:" + [[ $1 == '-' ]] && shift # for consistency with die() and dieSyntax(), accept '-' as an indicator that stdin input should be used. + if (( $# == 0 )); then # from stdin + { + printf '%s\n' "$kPREAMBLE" + sed 's/^/ &/' + } | echoColored 33 # yellow + else # from operands + echoColored 33 "$kPREAMBLE $*" + fi +} >&2 + +# -- Coloring convenience output functions +# They're based on echoColored(), and thus take either operands or stdin input. +# If input is provided via arguments, a trailing \n is added. +green() { echoColored 32 "$@"; } +red() { echoColored 31 "$@"; } +blue() { echoColored 34 "$@"; } +yellow() { echoColored 33 "$@"; } + +isDirEmpty() { + [[ -d ${1:-.} ]] || { echo "$FUNCNAME: ERROR: Argument not found or not a directory: $1" >&2; return 2; } + [[ $(shopt -s nullglob dotglob; cd "$1"; echo *) =~ ^$|^\.DS_Store$ ]] +} + +# SYNOPIS +# rreadlink fileOrDirPath +# DESCRIPTION +# Resolves fileOrDirPath to its ultimate target. +# This is a POSIX-compliant implementation of what GNU readlink's -f option does. +# Edge cases: won't work with filenames with embedded newlines or filenames containing the string ' -> '. +# EXAMPLE +# In a shell script, use the following to get that script's true directory of origin: +# $(dirname -- "$(rreadlink "$0")") +rreadlink() ( # Execute the function in a *subshell* to localize variables and the effect of `cd`. + + target=$1 fname= targetDir= + + # Try to make the execution environment as predictable as possible: + # All commands below are invoked via `command -p`, so we must make sure that `command` + # itself is not redefined as an alias or shell function. + # `command` is a *builtin* in bash, dash, ksh, zsh, and some platforms do not even have + # an external utility version of it (e.g, Ubuntu). + # `command` bypasses aliases and shell functions, and `-p` searches for external utilities + # in standard locations only, but note that this does *not* come into play if a *builtin* + # by the given name exists. zsh requires that option POSIX_BUILTINS be on to also find + # builtins with `command`. + { CDPATH=; \unalias command; \unset -f command; } >/dev/null 2>&1 + [ -n "$ZSH_VERSION" ] && options[POSIX_BUILTINS]=on # make zsh find *builtins* with `command` too. + + while :; do # Resolve potential symlinks until the ultimate target is found. + [ -L "$target" ] || [ -e "$target" ] || { printf '%s\n' "ERROR: '$target' does not exist." >&2; return 1; } + command -p cd "$(command -p dirname -- "$target")" # Change to target dir; necessary for correct resolution of target path. + fname=$(command -p basename -- "$target") # Extract filename. + if [ -L "$fname" ]; then + # Extract [next] target path, which may be defined + # *relative* to the symlink's own directory. + # Note: We parse `ls -l` output to find the symlink target + # which is the only POSIX-compliant, albeit somewhat fragile, way, + target=$(command -p ls -l "$fname") + target=${target#* -> } + continue # Resolve [next] symlink target. + fi + break # Ultimate target reached. + done + targetDir=$(command -p pwd -P) # Get canonical dir. path + # Output the ultimate target's canonical path. + command -p printf '%s\n' "${targetDir%/}/$fname" +) + + +# SYNOPSIS +# clearDir dir +# DESCRIPTION +# !!!!!!!!!!! USE WITH CAUTION !!!!!!!!!!!!!! +# Clears the contents of the specified directory. +# Exit code 0 indicates that clearing succeeded. +clearDir() ( # execute in subshell to localize effect of shopt + local d=${1?Missing directory argument} itms=() + [[ -d $d ]] || return 1 # Makes sure that dir. exists. + shopt -s dotglob nullglob # Make sure that hidden files are included when expanding `*` and that the result is empty if there are no items at all. + itms=( "$d"/* ) # Collect items, if any. + (( ${#itms[@]} == 0 )) && return 0 # If there are no items at all, return with exit code 0. + # There are items: try to remove them all - if there are permission problems, the exit code will be set to a non-zero value. + rm -rf "${itms[@]}" +) + +# Print the line that a shell initialization file must contain for `n` (and +# `n-update` and `n-uninstall`) to work correctly. +getShellInitFileLine() { + # Synthesize the - single - line to add to the init file, using Bash/Ksh/Zsh syntax. + # Definition of the N_PREFIX environment variable plus ensuring that + # $N_PREFIX/bin is in the $PATH, followed by the identifying comment. + printf '%s; %s %s' \ + "export N_PREFIX=\"${N_PREFIX/#$HOME/\$HOME}\"" \ + "[[ :\$PATH: == *\":\$N_PREFIX/bin:\"* ]] || PATH+=\":\$N_PREFIX/bin\"" \ + "$kINIT_FILE_LINE_ID" +} + +# SYNOPSIS +# modifyShellInitFile +# DESCRIPTION +# Modifies the relevant initialization file for the current user's shell by +# adding a SINGLE line composed of: +# - export N_PREFIX=... command +# - an add-bin-Dir-to-$PATH-if not-yet-there command. +# Outputs the full path of the initialization file modified. +modifyShellInitFile() { + + local line initFile existingLine initFileContents + + # Get the line to add to the init file. + line=$(getShellInitFileLine) + + # Determine the shell-specific initialization file. + if [[ -n $INSTALL_N_TEST_OVERRIDE_SHELL_INIT_FILE ]]; then # override for testing + initFile=$INSTALL_N_TEST_OVERRIDE_SHELL_INIT_FILE + else + case "$(basename -- "$SHELL")" in + 'bash') + # !! Sadly, bash ONLY reads ~/.bash_profile in LOGIN shells, and on OSX (Darwin) ALL shells are login shells, so on OSX we must target ~/.bash_profile. + [[ $(uname) == 'Darwin' ]] && initFile=~/.bash_profile || initFile=~/.bashrc + ;; + 'ksh') + initFile=~/.kshrc + ;; + 'zsh') + initFile=~/.zshrc + ;; + *) + warn - < 1 )) && + die - <>"$initFile" || { echo "$errMsg" >&2; return 1; } + elif [[ "$existingLine" != "$line" ]]; then # A line from a previous installation of ours was found: update it. + # !! We do NOT use sed -i, because that is not safe, notably because it + # !! would destroy a symlink, if the target file happens to be one. + # !! Instead, we read the entire contents into memory, and rewrite + # !! the modified string using simply '>', which DOES preserve the + # !! existing inode and thus file attributes including symlink status. + # !! Also note that for simplicity and consistency we add the new line at the *end*. + initFileContents=$(grep -Ev "$kREGEX_CONFLICTING_LINE" "$initFile") + printf '%s\n\n%s\n' "$initFileContents" "$line" > "$initFile" || die "$errMsg" + fi + fi + + printf '%s\n' "$initFile" + + return 0 +} + +# SYNOPSIS +# parseSemVer [-2] version +# DESCRIPTION +# Parses the specified semver-2.0-compatible version into its components (see http://semver.org/). +# If you specify option -2, only the . part must be present. +# Nothing is output if the version is not semver-compatible, and the return value is set to 1. +# Each component is returned on its own line, up to and including the last component found: +# Line 1 == major, line 2 == minor, line 3 == patch, line 4 == pre-release ID, line 5 == build metadata +# Thus, you get 2-5 lines of output, but note that if build metada was specified without also +# specifying a pre-release ID, line 4 will be empty. +# EXAMPLES +# parseSemVer 0.5.12 # -> $'0\n5\n12' +# parseSemVer 0.5.12 # -> $'0\n5\n12' +# parseSemVer 0.5.12-pre # -> $'0\n5\n12\npre' +# parseSemVer 0.5.12-pre+build7 # -> $'0\n5\n12\npre\nbuild7' +# parseSemVer 0.5.12+build7 # -> $'0\n5\n12\n\nbuild7' +parseSemVer() { + local onlyMajorMinorRequired=0 + [[ $1 == '-2' ]] && { onlyMajorMinorRequired=1; shift; } + (( $# == 1 )) || return 2 + + # Parse into major, minor, patch, and *roughly* into pre-release identifiers and metadata. + local num='([0-9]|[1-9][0-9]+)' # a decimal integer, but leading zeros are not allowed + local idList='([0-9A-Za-z.-]+)' # looser-than-required expression for the sub-identifiers making up the pre-release and metada parts; additional validation required + # [[ $1 =~ ^$num\.$num(\.$num(-$idList(\+$idList)?)?)?$ ]] || return 1 + [[ $1 =~ ^$num\.$num(\.$num(-$idList)?(\+$idList)?)?$ ]] || return 1 + + # See if we have at least major, minor, patch, or, if -2 was specified, major and minor. + local n major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[4]} prId=${BASH_REMATCH[6]} buildMd=${BASH_REMATCH[8]} + [[ -n $patch ]] || (( onlyMajorMinorRequired )) || return 1 + + # Validate the optional pre-release part and the metadata part, each composed of + # a list of non-empty, dot-separated sub-identifiers that are either decimal integers without + # leading zeros or strings composed of any mix of [0-9A-Za-z-] + local id ids=() + for n in prId buildMd; do + if [[ -n ${!n} ]]; then + IFS=. read -ra ids <<<"${!n}" # break into '.'-separated sub-IDs + [[ ${!n} =~ \.$ ]] && return 1 # must not end in '.' (if the last char. is an IFS char, `read` ignores it). + for id in "${ids[@]}"; do + [[ -z $id ]] && return 1 # empty sub-IDs not allowed. + [[ -n $(tr -d '[0-9]' <<<"$id") ]] && continue # sub-ID contains non-digits - no further validation required + [[ $id =~ ^$num$ ]] || return 1 # otherwise: a decimal integer - make sure it has no leading zeros, as with major, minor, patch. + done + fi + done + + # Output all components found. + local all=0 + [[ -n $buildMd ]] && all=1 + for n in major minor patch prId buildMd; do + [[ $all -eq 0 && -z ${!n} ]] && break + printf '%s\n' "${!n}" + done + + return 0 +} + + +# +# END: Helper functions +# + +# +# MAIN SCRIPT BODY +# + +unset CDPATH # to prevent unpredictable `cd` behavior +[[ -t 1 ]] || kNO_COLOR=1 # turn off colored output if stdout is not connected to a terminal + +# Output version number and exit, if requested. Note that the `ver='...'` statement is automatically updated by `make version VER=` - DO keep the 'v' prefix in the variable _definition_. +[[ $1 == '--version' ]] && { ver='v0.4.0'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } + +# !! AS OF n 1.3.0, n ITSELF ONLY WORKS WITH curl, NOT ALSO WITH wget. +# !! Once n also supports wget, mention wget as an alternative in the help text. +if [[ $1 == '--help' || $1 == '-h' ]]; then + cat <...] + +DESCRIPTION + Directly installs n, the Node.js version manager, which bypasses the need to + manually install a Node.js version first. + + Additionally, installs $kUPDATE_SCRIPT for updating n, + and $kUNINSTALL_SCRIPT for uninstallation. + + On successful installation of n, the specified Node.js (s) + are installed; by default, this is the latest stable Node.js version. + + To opt out, specify '-' as the only version argument. + + Supported version specifiers: + + * stable ... installs the latest stable version + * latest ... the latest version available overall + * lts ... the LTS (long-term stability) version + * otherwise, specify an explicit version numer, such as '0.12' or '0.10.35' + + If multiple versions are specified, the first one will be made active. + + The default installation directory is: + + ${kPREFIX_DIR/#$HOME/~} + + which can be overridden by setting environment variable N_PREFIX to an + absolute path before invocation; either way, however, the installation + directory must either not exist yet or be empty. + + If your shell is Bash, Ksh, or Zsh, the relevant initialization file will be + modified so as to: + - export environment variable \$N_PREFIX to point to the installation dir. + - ensure that the directory containing the n executable, \$N_PREFIX/bin, + is in the \$PATH. + Note that you either have to open a new terminal tab/window or re-source + the relevant initialization file before you can use n and Node.js. + For any other shell you'll have to make these modifications yourself. + You can also explicitly opt out of the modification with -n. + + Options: + + -t + Merely tests if all installation prerequisites are met, which is signaled + with an exit code of 0. + + -y + Assumes yes as the reply to all prompts; in other words: runs unattended + by auto-confirming the confirmation prompt. + + -q + Like -y, except that, additionally, all status messages are suppressed, + including the information and progress bar normally displayed by n while + installing Node.js versions. + + -n + Suppresses updating of the relevant shell initialization file. + For instance, this allows for custom setups where all exports are + "out-sourced" to an external file that is then sourced from the + shell-initialization file; however, note that you'll then have to edit + the out-sourced file *manually* - instructions will be printed. + + For more information, see $kTHIS_REPO_URL + +PREREQUISITES + bash ... to run this script and n itself. + curl ... to download helper scripts from GitHub and run n itself. + git ... to clone n's GitHub repository and update n later. + GNU make ... to run n's installation procedure. + +EXAMPLES + # Install n and the latest stable Node.js version, with + # interactive prompt: + $kTHIS_NAME + # Only test if installation to the specified location would work. + N_PREFIX=~/util/n $kTHIS_NAME -t + # Automated installation of n, without installing the latest + # stable Node.js version. + $kTHIS_NAME -y - + # Automated installation of n, followed by automated installation + # of the LTS and the latest stable Node.js versions, as well + # as the latest 0.8.x version. + $kTHIS_NAME -y lts latest 0.8 +EOF + exit 0 +fi + +# Check for prerequisites. +preReqMsg= +# !! AS OF n 1.3.0, n ITSELF ONLY WORKS WITH curl, NOT ALSO WITH wget. +# !! Once n also supports wget, remove 'curl' from this `for` loop and activate +# !! the curl-OR-wget command below. +for exe in curl git; do + [[ -n $(command -v "$exe") ]] || preReqMsg+="${preReqMsg:+$'\n'}\`$exe\` not found, which is required for operation." +done +# +# !! ACTIVATE THE FOLLOWING ONCE n ITSELF SUPPORTS wget. +# [[ -n $(command -v curl) || -n $(command -v wget) ]] || preReqMsg+="${preReqMsg:+$'\n'}Neither \`curl\` nor \`wget\` found; one of them is required for operation." +# +# !! n's own installation procedure, `make install`, unfortunately currently (1.3.0) requires GNU make (due to use of conditional assignment `?=`), even though it would be simple to make it +# !! POSIX-compliant; for now, we therefore explicitly require GNU make. +# !! However, this is a hypothetical concern, because, as of n 1.3.0, n only works with *prebuilt* binaries downloadable from https://nodejs.org/dist/, and, as of Node.js v0.12.4, +# !! prebuilt binaries only exist for Linux, Darwin (OSX) (and Windows) - if building Node.js from source were supported, however, GNU make would be required for that, too. +for makeExe in make gmake; do + "$makeExe" --version 2>/dev/null | grep -Fq "GNU Make" && break + [[ $makeExe == 'make' ]] && continue # if 'make' isn't GNU Make, try again with 'gmake'. + preReqMsg+="${preReqMsg:+$'\n'}GNU Make not found, which is required for operation."$'\n'"On FreeBSD and PC-BSD, for instance, you can download it with \`sudo pkg install gmake\`." +done +[[ -z $preReqMsg ]] || die - <<<"$preReqMsg" + +# Parse options. +skipPrompts=0 testPrerequisitesOnly=0 skipInitFileUpdate=0 quiet=0 +while getopts ':ytnq' opt; do + [[ $opt == '?' ]] && dieSyntax "Unknown option: -$OPTARG" + [[ $opt == ':' ]] && dieSyntax "Option -$OPTARG is missing its argument." + case "$opt" in + t) + testPrerequisitesOnly=1 + ;; + y) + skipPrompts=1 + ;; + n) + skipInitFileUpdate=1 + ;; + q) + quiet=1 + ;; + *) + die "DESIGN ERROR: option -$opt not handled." + ;; + esac +done +shift $((OPTIND - 1)) + +# Determine what Node.js versions to install later. +if (( $# == 0 )); then # no operands + # Install the latest stable Node.js version by default. + versionsToInstall=( 'stable' ) +else # operands specified: interpret them as Node.js versions to install + # *Syntactically* validate version numbers specified, if any: i.e., + # make sure they're either 'stable', 'latest', or either .. + # or .. A '-' suppresses the default version ('stable') to install. + # Note that checking for the actual availability of versions would be too time-consuming. + versionsToInstall=() + for ver; do + case $ver in + -) + : # means: do NOT install the default ('stable'); typically, + # we expect that to be the only operand, since an explicit list + # of operands always overrides the default, but we don't enforce this. + ;; + lts|stable|latest|io:stable|io:latest) # symbolic names for latest stable / unstable versions + versionsToInstall+=( "$ver" ) + ;; + *) # must be a version number in the form .[.] + componentCount=$(parseSemVer -2 "${ver#io:}" | wc -l) + (( componentCount == 2 || componentCount == 3 )) || + dieSyntax - <<<"'$ver' is not a valid Node.js version specifier."$'\n'"(Must be 'lts', 'stable', 'latest', or .[.].)" # , optionally prefixed with 'io:' + versionsToInstall+=( "$ver" ) + ;; + esac + done +fi + +# !! We prevent installation if the `n` or `npm` or `node` or `iojs` binaries are in the path, implying +# !! that either n or Node.js are already installed - whether they were installed with +# !! this utility or not. +existingExes=() +for exe in n node iosj npm; do + # Note that `command -v` on Linux and OSX supports multiple arguments, but POSIX mandates only 1. + exePath=$(command -v "$exe") && existingExes+=( "$exePath" ) +done +if (( ${#existingExes[@]} > 0 )); then + + die - 3 < 0 )) && cat <&2; exit 3; } + [[ $promptInput != [yY] ]] && { echo "Invalid input; please try again." 1>&2; continue; } + break + done + +fi + +# Getting here means that installation prerequisites are fulfilled and the +# intent to install was [auto-]confirmed. + +# Derive additional paths: +nRepoDir=${nDir}/.repo +nBinDir=${N_PREFIX}/bin + +# Clone n's repository into "${N_PREFIX}/n/.repo" +# To deal with possible CRLF issues (see below), suppress the normally automatich checkout (populating the working tree). +(( quiet )) || echo "-- Cloning $kN_REPO_URL to '$nRepoDir'..." +git clone --depth 1 --no-checkout --quiet "$kN_REPO_URL" "$nRepoDir/" >/dev/null || die "Aborting, because cloning n's GitHub repository into '$nRepoDir' failed." + +(( quiet )) || echo "-- Running local n installation to '$nBinDir'..." +# Note: Since the user may have `core.autocrlf` set to `true` globaly, we must make sure that we turn it off for the `n` repo first. +# Performing a checkout is only safe afterwards. +(cd "$nRepoDir" && git config core.autocrlf input && git checkout --quiet && PREFIX="$N_PREFIX" "$makeExe" install >/dev/null) || die "Aborting, because n's own installation procedure failed." + +# Modify the relevant shell initialization file. +if (( $skipInitFileUpdate )); then + + initFile= + (( quiet )) || cat </dev/null && helpersCopiedLocally=1 + +fi + +if (( ! helpersCopiedLocally )); then # Running from GitHub with `curl ... | bash`, or from a lone local copy of `n-install` without its helper scripts present. + + # Find a SHA-256 checksum utility and construct a verification command. + shaSumVerifyCmd= + [[ -n $(command -v sha256sum) ]] && shaSumVerifyCmd=( 'sha256sum' '-c' '--status' ) + [[ -z $shaSumVerifyCmd && -n $(command -v shasum) ]] && shaSumVerifyCmd=( 'shasum' '-a' '256' '-c' '--status' ) + + # Download helper scripts from GitHub. + if [[ -z $shaSumVerifyCmd ]]; then # No SHA checksum-verification utility found - this should not happen. + warn - </dev/null + fi + +fi + +# At this point we consider installation of n itself successful, even if +# installation of Node.js versions below fails or is aborted. +# Therefore, we now deactivate the cleanup handler. +trap - "${sigs[@]}" + +# Install the requested Node.js versions, if any. + +toInstallCount=${#versionsToInstall[@]} +installedCount=0 + +if (( toInstallCount > 0 )); then + + (( quiet )) || echo "-- Installing the requested Node.js version(s)..." + + firstInstalledVerArgs=() i=0 + (( quiet )) && exec 3<&1 1> /dev/null # suppress stdout from `n` + for ver in "${versionsToInstall[@]}"; do + (( quiet )) || echo " $(( ++i )) of ${toInstallCount}: ${ver}..." + (( quiet )) && args=( '-q' ) || args=() + [[ $ver == 'io:'* ]] && args+=( io "${ver#io:}" ) || args+=( "$ver" ) + # Note: To be safe, we place $nBinDir FIRST in the path for this invocation. + if PATH="$nBinDir:$PATH" N_PREFIX="$N_PREFIX" n "${args[@]}"; then + (( ++installedCount == 1 )) && firstInstalledVerArgs=( "${args[@]}" ) + else + warn "Failed to install version '$ver'." + fi + done + (( quiet )) && exec 1>&3 3>&- # restore stdout + + # Activate the first successfully installed version (otherwise the last + # version installed would be the active one). + if (( installedCount > 1 )); then + # Note that n uses the same syntax for installing and activating an installed version. + PATH="$nBinDir:$PATH" N_PREFIX="$N_PREFIX" n "${firstInstalledVerArgs[@]}"|| warn "Failed to activate version '$ver'." + fi + +fi + +# Report success and provide further instructions. +# !! Do not use unbalanced single quotes - such as an apostrophe - in the embedded +# !! here-docs below, as they inexplicably break the enclosing here-document in Bash 3.x. +(( quiet )) || cat < 0 )) && + echo " The active Node.js version is: $("$nBinDir"/node --version)" || + echo " Run \`n stable\` to install the latest stable Node.js version." +) + + Run \`n -h\` for help. + To update n later, run \`$kUPDATE_SCRIPT\`. + To uninstall, run \`$kUNINSTALL_SCRIPT\`. + +$( [[ -n $initFile ]] && cat <` - DO keep the 'v' prefix in the variable _definition_. -[ "$1" = '--version' ] && { ver='v0.3.7'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } +[ "$1" = '--version' ] && { ver='v0.4.0'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } # Command-line help. if [ "$1" = '--help' ] || [ "$1" = '-h' ]; then diff --git a/bin/n-update b/bin/n-update index 59c720e..e2347c0 100755 --- a/bin/n-update +++ b/bin/n-update @@ -124,7 +124,7 @@ unset CDPATH # to prevent unpredictable `cd` behavior [[ -t 1 ]] || kNO_COLOR=1 # turn off colored output if stdout is not connected to a terminal # Output version number and exit, if requested. Note that the `ver='...'` statement is automatically updated by `make version VER=` - DO keep the 'v' prefix in the variable _definition_. -[ "$1" = '--version' ] && { ver='v0.3.7'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } +[ "$1" = '--version' ] && { ver='v0.4.0'; echo "$kTHIS_NAME ${ver#v}"$'\nFor license information and more, visit https://git.io/n-install-repo'; exit 0; } # Command-line help. if [ "$1" = '--help' ] || [ "$1" = '-h' ]; then diff --git a/package.json b/package.json index c64096e..05d3ade 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "n-install", "description": "installs n, the Node.js version manager, without needing to install Node.js first", "private": true, - "version": "0.3.7", + "version": "0.4.0", "os": [ "darwin", "linux" diff --git a/test/3 Checksum verification succeeds b/test/3 Checksum verification succeeds new file mode 100755 index 0000000..56d10df --- /dev/null +++ b/test/3 Checksum verification succeeds @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +# --- +# IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT +# to ensure that this package's 'bin/' subfolder is added to the path so that +# this package's CLIs can be invoked by their mere filename in the rest +# of the script. +# --- +PATH=${PWD%%/test*}/bin:$PATH + +# Helper function for error reporting. +die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } + +# Verify that the checksums embedded in n-install for n-upate and n-install +# are valid. + +n_install_bin=$(command -v n-install) +n_update_bin=$(command -v n-update) +n_uninstall_bin=$(command -v n-uninstall) + +# Find a SHA-256 checksum utility and construct a verification command. +shaSumCmd= +[[ -n $(command -v sha256sum) ]] && shaSumCmd=( 'sha256sum' ) +[[ -z $shaSumCmd && -n $(command -v shasum) ]] && shaSumCmd=( 'shasum' '-a' '256' ) +[[ -n $shaSumCmd ]] || die "No SHA-256 utility found." + +shaSumVerifyCmd=( "${shaSumCmd[@]}" ) +shaSumVerifyCmd+=( '-c' ) + +# Extract embedded checksums from `n-install` and verify them. +# NOTE: This relies on `n-install` containing lines in exactly the following format +# with embedded checksums - only the checksums may vary: +# kSHA256_SUMS=( # SHA-256 checksums for the helper scripts. +# "cbe687a7d9a1f8415c67923c6bd793b97c1f9c2e240189136bb100fcd4b933a0 $kUPDATE_SCRIPT" +# "9067282260e197a796c3126433401f7a3288c0b4fd3a7407fa4b62f25200499a $kUNINSTALL_SCRIPT" +# ) +sed ' + /^kSHA256_SUMS=/,/^)/!d; //d + s/^[^"]*"// + s/"// +' "$n_install_bin" | + sed " + s@\$kUPDATE_SCRIPT@$n_update_bin@ + s@\$kUNINSTALL_SCRIPT@$n_uninstall_bin@ + " | "${shaSumVerifyCmd[@]}" || die "Checksum verification failed - ensure that the helper-script checksums embedded in n-install are current. +Checksums should be (try running \`make update-checksum\`): + $("${shaSumCmd[@]}" "$n_update_bin") + $("${shaSumCmd[@]}" "$n_uninstall_bin") +" + +exit 0 diff --git a/test/3 Fail with unusable install dirs b/test/4 Fail with unusable install dirs similarity index 100% rename from test/3 Fail with unusable install dirs rename to test/4 Fail with unusable install dirs diff --git a/test/4 Meet prerequisites with empty or non-extant dir b/test/5 Meet prerequisites with empty or non-extant dir similarity index 100% rename from test/4 Meet prerequisites with empty or non-extant dir rename to test/5 Meet prerequisites with empty or non-extant dir diff --git a/test/5 Modify shell initialization files b/test/6 Modify shell initialization files similarity index 100% rename from test/5 Modify shell initialization files rename to test/6 Modify shell initialization files diff --git a/test/6 Do not modify shell initialization files, if requested b/test/7 Do not modify shell initialization files, if requested similarity index 100% rename from test/6 Do not modify shell initialization files, if requested rename to test/7 Do not modify shell initialization files, if requested diff --git a/test/7 Run successful and failed installations and updates b/test/8 Run successful and failed installations and updates similarity index 100% rename from test/7 Run successful and failed installations and updates rename to test/8 Run successful and failed installations and updates diff --git a/test/8 Run successful and failed uninstallations b/test/9 Run successful and failed uninstallations similarity index 100% rename from test/8 Run successful and failed uninstallations rename to test/9 Run successful and failed uninstallations diff --git a/util/update-checksums b/util/update-checksums new file mode 100755 index 0000000..577e573 --- /dev/null +++ b/util/update-checksums @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# Helper script that updates the helper-script SHA-256 checksum embedded +# in `n-install` + +# Helper function for error reporting. +die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } + +# Determine path of `n-install` and the helper scripts. +n_install_bin="$(dirname -- "$BASH_SOURCE")/../bin/n-install" +n_update_bin="$(dirname -- "$BASH_SOURCE")/../bin/n-update" +n_uninstall_bin="$(dirname -- "$BASH_SOURCE")/../bin/n-uninstall" + +# Find a SHA-256 checksum utility and construct a command. +shaSumCmd= +[[ -n $(command -v sha256sum) ]] && shaSumCmd=( 'sha256sum' ) +[[ -z $shaSumCmd && -n $(command -v shasum) ]] && shaSumCmd=( 'shasum' '-a' '256' ) +[[ -n $shaSumCmd ]] || die "No SHA-256 utility found." + +chkSumUpdateScript=$("${shaSumCmd[@]}" "$n_update_bin" | awk '{ print $1 }') +chkSumUninstallScript=$("${shaSumCmd[@]}" "$n_uninstall_bin" | awk '{ print $1 }') + +# Updat the embedded checksums in `n-install`. +# NOTE: This relies on `n-install` containing lines in exactly the following format +# with embedded checksums - only the checksums may vary: +# kSHA256_SUMS=( # SHA-256 checksums for the helper scripts. +# "cbe687a7d9a1f8415c67923c6bd793b97c1f9c2e240189136bb100fcd4b933a0 $kUPDATE_SCRIPT" +# "9067282260e197a796c3126433401f7a3288c0b4fd3a7407fa4b62f25200499a $kUNINSTALL_SCRIPT" +# ) +awk -v chkSumUpdateScript="$chkSumUpdateScript" -v chkSumUninstallScript="$chkSumUninstallScript" ' + NF == 2 && $NF == "$kUPDATE_SCRIPT\"" { sub(/"[a-f0-9]+/, "\"" chkSumUpdateScript) } + NF == 2 && $NF == "$kUNINSTALL_SCRIPT\"" { sub(/"[a-f0-9]+/, "\"" chkSumUninstallScript) } + 1 # print line +' "$n_install_bin" > $$.tmp && mv $$.tmp "$n_install_bin" || die + +# Make the replaced n-install executable again. +chmod a+x "$n_install_bin" + +echo "Helper-script checksums embedded in n-install successfully updated."