diff --git a/bin/JSON.sh b/bin/JSON.sh new file mode 120000 index 0000000..f3a245b --- /dev/null +++ b/bin/JSON.sh @@ -0,0 +1 @@ +../node_modules/JSON.sh/JSON.sh \ No newline at end of file diff --git a/bin/nodenv-package-json b/bin/nodenv-package-json new file mode 120000 index 0000000..777cd74 --- /dev/null +++ b/bin/nodenv-package-json @@ -0,0 +1 @@ +../libexec/nodenv-package-json \ No newline at end of file diff --git a/bin/plugin_root b/bin/plugin_root deleted file mode 100755 index d76a072..0000000 --- a/bin/plugin_root +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -# -# Writes the full path to the root of this plugin to standard out. -# Use to source or run files in libexec/ -# -# Adapted from: -# http://www.ostricher.com/2014/10/the-right-way-to-get-the-directory-of-a-bash-script/ -# http://stackoverflow.com/a/12694189/407845 -src="${BASH_SOURCE[0]}" - -# while $src is a symlink, resolve it -while [ -h "$src" ]; do - dir="${src%/*}" - src="$( readlink "$src" )" - - # If $src was a relative symlink (so no "/" as prefix), - # need to resolve it relative to the symlink base directory - [[ $src != /* ]] && src="$dir/$src" -done -dir="${src%/*}" - -if [ -d "$dir" ]; then - echo "$dir/.." -else - echo "$PWD/.." -fi diff --git a/bin/semver.sh b/bin/semver.sh new file mode 120000 index 0000000..0bde939 --- /dev/null +++ b/bin/semver.sh @@ -0,0 +1 @@ +../node_modules/sh-semver/semver.sh \ No newline at end of file diff --git a/etc/nodenv.d/version-name/package-json-engine.bash b/etc/nodenv.d/version-name/package-json-engine.bash index f443ada..a2b09e9 100644 --- a/etc/nodenv.d/version-name/package-json-engine.bash +++ b/etc/nodenv.d/version-name/package-json-engine.bash @@ -1,11 +1,10 @@ -#!/bin/bash - -# shellcheck source=libexec/nodenv-package-json-engine -source "$(plugin_root)/libexec/nodenv-package-json-engine" +if [ -n "$(nodenv-sh-shell 2>/dev/null)" ] || + [ -n "$(nodenv-local 2>/dev/null)" ]; then + return +fi -if ! NODENV_PACKAGE_JSON_VERSION=$(get_version_respecting_precedence); then - echo "package-json-engine: version satisfying \`$(get_expression_respecting_precedence)' not installed" >&2 - exit 1 -elif [ -n "$NODENV_PACKAGE_JSON_VERSION" ]; then - export NODENV_VERSION="${NODENV_PACKAGE_JSON_VERSION}" +if NODENV_PACKAGE_JSON_VERSION=$(nodenv-package-json 2>/dev/null) && + [ -n "$NODENV_PACKAGE_JSON_VERSION" ]; then + # shellcheck disable=2034 + NODENV_VERSION=$NODENV_PACKAGE_JSON_VERSION fi diff --git a/etc/nodenv.d/version-origin/package-json-engine.bash b/etc/nodenv.d/version-origin/package-json-engine.bash index 97c0afd..3d0cd88 100644 --- a/etc/nodenv.d/version-origin/package-json-engine.bash +++ b/etc/nodenv.d/version-origin/package-json-engine.bash @@ -1,9 +1,33 @@ -#!/bin/bash +if [ -n "$(nodenv-sh-shell 2>/dev/null)" ] || + [ -n "$(nodenv-local 2>/dev/null)" ]; then + return +fi + +READLINK=$(type -p greadlink readlink | head -1) +[ -n "$READLINK" ] || return 1 + +abs_dirname() { + local cwd="$PWD" + local path="$1" + + while [ -n "$path" ]; do + cd "${path%/*}" || return 1 + local name="${path##*/}" + path="$($READLINK "$name" || true)" + done + + pwd + cd "$cwd" || return 1 +} + +bin_path="$(abs_dirname "${BASH_SOURCE[0]}")/../../../libexec" -# shellcheck source=libexec/nodenv-package-json-engine -source "$(plugin_root)/libexec/nodenv-package-json-engine" +[ -d "$bin_path" ] || return 1 -ENGINES_EXPRESSION=$(get_expression_respecting_precedence); -if [ -n "$ENGINES_EXPRESSION" ]; then - export NODENV_VERSION_ORIGIN="package-json-engine matching $ENGINES_EXPRESSION" +if NODENV_PACKAGE_JSON_VERSION=$(nodenv-package-json 2>/dev/null) && + [ -n "$NODENV_PACKAGE_JSON_VERSION" ]; then + NODENV_PACKAGE_JSON_FILE=$("$bin_path/nodenv-package-json-file") + NODENV_PACKAGE_JSON_SPEC=$("$bin_path/nodenv-package-json-file-read" "$NODENV_PACKAGE_JSON_FILE") + # shellcheck disable=2034 + NODENV_VERSION_ORIGIN="satisfying \`$NODENV_PACKAGE_JSON_SPEC' from $NODENV_PACKAGE_JSON_FILE#engines.node" fi diff --git a/libexec/nodenv-package-json b/libexec/nodenv-package-json new file mode 100755 index 0000000..ad72c3b --- /dev/null +++ b/libexec/nodenv-package-json @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Usage: nodenv package-json +# Summary: Show the local application-specific Node version from package.json +# +# Shows the highest-installed node version satisfying package.json#engines. +# + +set -e +[ -n "$NODENV_DEBUG" ] && set -x + +abort() { + echo "package-json-engine: $1" >&2 + exit 1 +} + +READLINK=$(type -p greadlink readlink | head -1) +[ -n "$READLINK" ] || abort "cannot find readlink - are you missing GNU coreutils?" + +abs_dirname() { + local cwd="$PWD" + local path="$1" + + while [ -n "$path" ]; do + cd "${path%/*}" + local name="${path##*/}" + path="$($READLINK "$name" || true)" + done + + pwd + cd "$cwd" +} + +bin_path="$(abs_dirname "$0")" + +matching_version() { + local version_spec=$1 + local -a installed_versions + while IFS= read -r v; do + installed_versions+=( "$v" ) + done < <(nodenv versions --bare --skip-aliases | grep -e '^[[:digit:]]') + + local fast_guess + fast_guess=$(semver.sh -r "$version_spec" "${installed_versions[@]:${#installed_versions[@]}-1}" | tail -n 1) + + # Most #engine version specs just specify a baseline version, + # which means most likely, the highest installed version will satisfy + # This does a first pass with just that single version in hopes it satisfies. + # If so, we can avoid the cost of sh-semver sorting and validating across + # all the installed versions. + if [ -n "$fast_guess" ]; then + echo "$fast_guess" + return 0 + fi + + local match + match=$(semver.sh -r "$version_spec" "${installed_versions[@]}" | tail -n 1) + + if [ -n "$match" ]; then + echo "$match" + else + return 1 + fi +} + + +if ! NODENV_PACKAGE_JSON_FILE="$("$bin_path/nodenv-package-json-file")"; then + abort "no package.json found for this directory" +fi + +if ! version_spec="$("$bin_path/nodenv-package-json-file-read" "$NODENV_PACKAGE_JSON_FILE")"; then + abort "no engine version configured for this package" +fi + +if ! matching_version "$version_spec"; then + abort "no version found satisfying \`$version_spec'" +fi diff --git a/libexec/nodenv-package-json-engine b/libexec/nodenv-package-json-engine deleted file mode 100755 index 58f8db0..0000000 --- a/libexec/nodenv-package-json-engine +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -# -# If a custom Node version is not already defined, we look -# for a Node version semver expressing in the current tree's package.json. -# If we find a fixed version, we print it out. If we find a range we -# test the installed versions against that range and print the -# greatest matching version. - -# Vendored scripts: -JSON_SH="$(plugin_root)/node_modules/JSON.sh/JSON.sh" -SEMVER="$(plugin_root)/node_modules/sh-semver/semver.sh" - -# Exits non-zero if this plugin should yield precedence -# Gives precedence to local and shell versions. -# Takes precedence over global version. -package_json_has_precedence() { - if [[ ( -z "$(nodenv local 2>/dev/null)" ) - && ( -z "$(nodenv sh-shell 2>/dev/null)" ) ]]; then - return; - else - return 1; - fi -} - -find_package_json_path() { - local package_json root="$1" - while [ -n "$root" ]; do - package_json="$root/package.json" - - if [ -r "$package_json" ] && [ -f "$package_json" ]; then - echo "$package_json" - return - fi - root="${root%/*}" - done -} - -extract_version_from_package_json() { - package_json_path="$1" - version_regex='\["engines","node"\][[:space:]]*"([^"]*)"' - # -b -n gives minimal output - see https://github.com/dominictarr/JSON.sh#options - [[ $("$JSON_SH" -b -n < "$package_json_path" 2>/dev/null) =~ $version_regex ]] - echo "${BASH_REMATCH[1]}" -} - -find_installed_version_matching_expression() { - version_expression="$1" - local -a installed_versions - while IFS= read -r v; do - installed_versions+=( "$v" ) - done < <(nodenv versions --bare --skip-aliases | grep -e '^[[:digit:]]') - - local fast_guess - fast_guess=$("$SEMVER" -r "$version_expression" "${installed_versions[@]:${#installed_versions[@]}-1}" | tail -n 1) - - # Most #engine version specs just specify a baseline version, - # which means most likely, the highest installed version will satisfy - # This does a first pass with just that single version in hopes it satisfies. - # If so, we can avoid the cost of sh-semver sorting and validating across - # all the installed versions. - if [ -n "$fast_guess" ]; then - echo "$fast_guess" - return - fi - - "$SEMVER" -r "$version_expression" "${installed_versions[@]}" | tail -n 1 -} - -get_version_respecting_precedence() { - if ! package_json_has_precedence; then return; fi - - package_json_path=$(find_package_json_path "$PWD") - if [ ! -e "$package_json_path" ]; then return; fi - - version_expression=$( - extract_version_from_package_json "$package_json_path" - ) - if [ -z "$version_expression" ]; then return; fi - - version=$( - find_installed_version_matching_expression "$version_expression" - ) - if [ -z "$version" ]; then return 1; fi - echo "$version" -} - -get_expression_respecting_precedence() { - if ! package_json_has_precedence; then return; fi - - package_json_path=$(find_package_json_path "$PWD") - if [ ! -e "$package_json_path" ]; then return; fi - - version_expression=$( - extract_version_from_package_json "$package_json_path" - ) - echo "$version_expression" -} diff --git a/libexec/nodenv-package-json-file b/libexec/nodenv-package-json-file new file mode 100755 index 0000000..2c0e2be --- /dev/null +++ b/libexec/nodenv-package-json-file @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# +# Usage: package-file [] +# Summary: Detect the package.json that sets the current nodenv version +# Options: +# Directory from which to find package.json +# [default: ${NODENV_DIR:-PWD}] + +set -e +[ -n "$NODENV_DEBUG" ] && set -x + +target_dir="$1" + +find_package_json() { + local package_json root="$1" + while ! [[ "$root" =~ ^//[^/]*$ ]]; do + package_json="$root/package.json" + + if [ -f "$package_json" ] && + [ -r "$package_json" ] && + [ -s "$package_json" ]; then + echo "$package_json" + return 0 + fi + [ -n "$root" ] || break + root="${root%/*}" + done + return 1 +} + +if [ -n "$target_dir" ]; then + find_package_json "$target_dir" +else + find_package_json "$NODENV_DIR" || { + [ "$NODENV_DIR" != "$PWD" ] && find_package_json "$PWD" + } +fi diff --git a/libexec/nodenv-package-json-file-read b/libexec/nodenv-package-json-file-read new file mode 100755 index 0000000..ddc39ba --- /dev/null +++ b/libexec/nodenv-package-json-file-read @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Usage: nodenv package-json-file-read +# +# Summary: Show the engines version-spec from package.json +# Options: +# Package.json file to read from +# + +set -e +[ -n "$NODENV_DEBUG" ] && set -x + +PACKAGE_JSON_FILE=$1 + +[ -f "$PACKAGE_JSON_FILE" ] || exit 1 + +extract_expression() { + local version_regex='\["engines","node"\][[:space:]]*"([^"]*)"' + # -b -n gives minimal output - see https://github.com/dominictarr/JSON.sh#options + if [[ $(JSON.sh -b -n 2>/dev/null) =~ $version_regex ]]; then + echo "${BASH_REMATCH[1]}" + else + return 1 + fi +} + +extract_expression < "$PACKAGE_JSON_FILE" diff --git a/package.json b/package.json index de3f77b..5c2aa8a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "bugs": { "url": "https://github.com/nodenv/nodenv-package-json-engine/issues" }, + "bin": "libexec/nodenv-package-json", "directories": { "bin": "bin", "test": "test" @@ -25,7 +26,7 @@ "libexec" ], "scripts": { - "lint": "git ls-files bin etc libexec test/*.bash | xargs shellcheck", + "lint": "git ls-files etc libexec test/*.bash | xargs shellcheck", "test": "bats ${CI:+--tap} test", "posttest": "npm run lint", "postversion": "npm publish", diff --git a/test/nodenv-package-json-engine.bats b/test/nodenv-package-json-engine.bats deleted file mode 100755 index 47d91d8..0000000 --- a/test/nodenv-package-json-engine.bats +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -@test 'Recognizes simple node version specified in package.json engines' { - in_package_for_engine 4.2.1 - - run nodenv version - assert_success - assert_output '4.2.1 (set by package-json-engine matching 4.2.1)' -} - -@test 'Prefers the greatest installed version matching a range' { - in_package_for_engine '^4.0.0' - - run nodenv version - assert_success - assert_output '4.2.1 (set by package-json-engine matching ^4.0.0)' -} - -@test 'Ignores non-matching installed versions' { - in_package_for_engine '^1.0.0' - - run nodenv version - # note the command completes successfully - assert_success - assert_output - <<-MSG -package-json-engine: version satisfying \`^1.0.0' not installed - (set by package-json-engine matching ^1.0.0) -MSG -} - -@test 'Prefers nodenv-local over package.json' { - in_package_for_engine 4.2.1 - nodenv local 5.0.0 - - run nodenv version - assert_success - assert_output "5.0.0 (set by $PWD/.node-version)" -} - -@test 'Prefers nodenv-shell over package.json' { - in_package_for_engine 4.2.1 - - NODENV_VERSION=5.0.0 run nodenv version - assert_success - assert_output "5.0.0 (set by NODENV_VERSION environment variable)" -} - -@test 'Prefers package.json over nodenv-global' { - in_package_for_engine 4.2.1 - nodenv global 5.0.0 - - run nodenv version-name - assert_success - assert_output '4.2.1' -} - -@test 'Is not confused by nodenv-shell shadowing nodenv-global' { - in_package_for_engine 4.2.1 - nodenv global 5.0.0 - - NODENV_VERSION=5.0.0 run nodenv version - assert_success - assert_output "5.0.0 (set by NODENV_VERSION environment variable)" -} - -@test 'Does not match arbitrary "node" key in package.json' { - in_package_with_babel_env - - run nodenv version-name - - assert_success - assert_output 'system' -} - -@test 'Handles missing package.json' { - in_example_package - - run nodenv version-name - - assert_success - assert_output 'system' -} - -@test 'Does not fail with unreadable package.json' { - in_example_package - touch package.json - chmod -r package.json - - run nodenv version-name - - assert_success - assert_output 'system' -} - -@test 'Does not fail with non-file package.json' { - in_example_package - mkdir package.json - - run nodenv version-name - - assert_success - assert_output 'system' -} - -@test 'Does not fail with empty or malformed package.json' { - in_example_package - - # empty - touch package.json - run nodenv version-name - assert_success - assert_output 'system' - - # non json - echo "foo" > package.json - run nodenv version-name - assert_success - assert_output 'system' - - # malformed - echo "{" > package.json - run nodenv version-name - assert_success - assert_output 'system' -} - -@test 'Handles multiple occurrences of "node" key' { - in_example_package - cat << JSON > package.json -{ - "engines": { - "node": "4.2.1" - }, - "presets": [ - ["env", { - "targets": { - "node": "current" - } - }] - ] -} -JSON - - run nodenv version-name - assert_success - assert_output '4.2.1' -} diff --git a/test/package-json-file-read.bats b/test/package-json-file-read.bats new file mode 100755 index 0000000..cef6a8e --- /dev/null +++ b/test/package-json-file-read.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats + +load test_helper + +read="$BATS_TEST_DIRNAME/../libexec/nodenv-package-json-file-read" + +@test 'Prints the version spec from engines.node' { + in_example_package + echo '{ "engines": { "node": ">= 8" } }' > package.json + + run $read package.json + + assert_success + assert_output '>= 8' +} + +@test 'Does not match arbitrary "node" key in package.json' { + in_example_package + cat << JSON > package.json +{ + "presets": [ + ["env", { + "targets": { + "node": "current" + } + }] + ] +} +JSON + + run $read package.json + + assert_failure + refute_output +} + +@test 'Errors with non-JSON file' { + in_example_package + echo "foo" > package.json + + run $read package.json + + assert_failure + refute_output +} + +@test 'Errors with malformed JSON file' { + in_example_package + echo "{" > package.json + + run $read package.json + + assert_failure + refute_output +} + +@test 'Prints the right "node" key' { + in_example_package + cat << JSON > package.json +{ + "engines": { + "node": "4.2.1" + }, + "presets": [ + ["env", { + "targets": { + "node": "current" + } + }] + ] +} +JSON + + run $read package.json + + assert_success + assert_output '4.2.1' +} diff --git a/test/package-json-file.bats b/test/package-json-file.bats new file mode 100755 index 0000000..b215a42 --- /dev/null +++ b/test/package-json-file.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats + +load test_helper + +nodenv_package_json_file="$BATS_TEST_DIRNAME/../libexec/nodenv-package-json-file" + +@test 'Looks in provided directory' { + in_example_package + cd .. + + run "$nodenv_package_json_file" "$EXAMPLE_PACKAGE_DIR" + + assert_success + assert_output "$EXAMPLE_PACKAGE_DIR/package.json" +} + +@test 'Looks in NODENV_DIR by default' { + in_example_package + cd .. + + NODENV_DIR="$EXAMPLE_PACKAGE_DIR" run "$nodenv_package_json_file" + + assert_success + assert_output "$EXAMPLE_PACKAGE_DIR/package.json" +} + +@test 'Falls back to PWD' { + in_example_package + + run "$nodenv_package_json_file" + + assert_success + assert_output "$EXAMPLE_PACKAGE_DIR/package.json" +} + +@test 'Works up the directory hierarchy' { + in_example_package + mkdir -p "sub/dir" + cd "sub/dir" + + run "$nodenv_package_json_file" + + assert_success + assert_output "$EXAMPLE_PACKAGE_DIR/package.json" +} + +@test 'Exits non-zero when missing package.json' { + in_example_package + rm package.json + + run "$nodenv_package_json_file" + + assert_failure + refute_output +} + +@test 'Treats non-readable same as missing' { + in_example_package + chmod -r package.json + + run "$nodenv_package_json_file" + + assert_failure + refute_output +} + +@test 'Treats non-file same as missing' { + in_example_package + rm package.json + mkdir package.json + + run "$nodenv_package_json_file" + + assert_failure + refute_output +} + +@test 'Treats empty same as missing' { + in_example_package + > package.json + + run "$nodenv_package_json_file" + + assert_failure + refute_output +} diff --git a/test/package-json.bats b/test/package-json.bats new file mode 100755 index 0000000..d0d41ec --- /dev/null +++ b/test/package-json.bats @@ -0,0 +1,27 @@ +#!/usr/bin/env bats + +load test_helper + +@test 'Recognizes simple node version specified in package.json engines' { + in_package_for_engine 4.2.1 + + run nodenv package-json + assert_success + assert_output '4.2.1' +} + +@test 'Prefers the greatest installed version matching a range' { + in_package_for_engine '^4.0.0' + + run nodenv package-json + assert_success + assert_output '4.2.1' +} + +@test 'Ignores non-matching installed versions' { + in_package_for_engine '^1.0.0' + + run nodenv package-json + assert_failure + assert_output "package-json-engine: no version found satisfying \`^1.0.0'" +} diff --git a/test/test_helper.bash b/test/test_helper.bash index 474907f..a0bfc1f 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -5,11 +5,11 @@ load '../node_modules/bats-assert/load' setup() { # common nodenv setup - unset NODENV_VERSION + unset NODENV_VERSION NODENV_DIR NODENV_HOOK_PATH local node_modules_bin=$BATS_TEST_DIRNAME/../node_modules/.bin - export PATH="$node_modules_bin:/usr/bin:/bin:/usr/sbin:/sbin" + export PATH="$BATS_TEST_DIRNAME/../bin:$node_modules_bin:/usr/bin:/bin:/usr/sbin:/sbin" export NODENV_ROOT="$BATS_TEST_DIRNAME/fixtures/nodenv_root" @@ -27,6 +27,7 @@ teardown() { in_example_package() { cd "$EXAMPLE_PACKAGE_DIR" || return 1 + echo '{}' > package.json } in_package_for_engine() { @@ -39,18 +40,3 @@ in_package_for_engine() { } JSON } - -in_package_with_babel_env() { - in_example_package - cat << JSON > package.json -{ - "presets": [ - ["env", { - "targets": { - "node": "current" - } - }] - ] -} -JSON -} diff --git a/test/version-name-hook.bats b/test/version-name-hook.bats new file mode 100755 index 0000000..c98c86b --- /dev/null +++ b/test/version-name-hook.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats + +load test_helper + +@test 'Prefers nodenv-shell over package.json' { + in_package_for_engine 4.2.1 + + NODENV_VERSION=5.0.0 run nodenv version-name + assert_success + assert_output '5.0.0' +} + +@test 'Prefers nodenv-local over package.json' { + in_package_for_engine 4.2.1 + nodenv local 5.0.0 + + run nodenv version-name + assert_success + assert_output '5.0.0' +} + +@test 'Prefers package.json over nodenv-global' { + in_package_for_engine 4.2.1 + nodenv global 5.0.0 + + run nodenv version-name + assert_success + assert_output '4.2.1' +} + +@test 'Mutes error output from nodenv-package' { + in_example_package + + run nodenv version-name + + assert_success + assert_output 'system' +} diff --git a/test/version-origin-hook.bats b/test/version-origin-hook.bats new file mode 100755 index 0000000..5fdba2e --- /dev/null +++ b/test/version-origin-hook.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats + +load test_helper + +@test 'Prefers nodenv-shell over package.json' { + in_package_for_engine 4.2.1 + + NODENV_VERSION=5.0.0 run nodenv version-origin + assert_success + assert_output 'NODENV_VERSION environment variable' +} + +@test 'Prefers nodenv-local over package.json' { + in_package_for_engine 4.2.1 + nodenv local 5.0.0 + + run nodenv version-origin + assert_success + assert_output "$PWD/.node-version" +} + +@test 'Prefers package.json over nodenv-global' { + in_package_for_engine '>= 4' + nodenv global 5.0.0 + + run nodenv version-origin + assert_success + assert_output "satisfying \`>= 4' from $PWD/package.json#engines.node" +} + +@test 'Mutes error output from nodenv-package' { + in_example_package + + run nodenv version-origin + + assert_success + assert_output "$NODENV_ROOT/version" +}