diff --git a/CHANGELOG.md b/CHANGELOG.md index f124707..036a009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -https://github.com/beplus/be/compare/v0.9.0...main +https://github.com/beplus/be/compare/v0.10.0...main + +## [v0.10.0] (10/07/2026) + +Make `be auto` install exactly the beplus CLI version the repository pins, so that a CLI release +cannot change what a deploy runs without a commit in that repository. `auto` reads +`"cli": { "version": "x.y.z" }` from the nearest `beplus.estate.json`, in the current directory or +the closest one above it, and installs that version without consulting the release index. No +manifest, a manifest without a pin, a pin that is not one exact version (`2`, `2.11`, `latest`, +`^2.11.0`) or a file that is not valid JSON fails with a message naming the file; nothing falls +back to the newest release, and a manifest further up never stands in for the nearest one. A +leading `v` and pre-releases are accepted, as everywhere in `be`. The manifest is read with `node`, +or with `jq` where there is no `node`. `be ls-remote auto`, `be which auto`, `be run auto` and +`be exec auto` resolve the same pin. + +`auto` no longer reads `.n-node-version`, `.node-version`, `.nvmrc` or `engines.node` in +`package.json`, which it inherited from `n` and which name Node.js versions, not beplus CLI ones. +The help text promised `.bepluscloud`, `.beplus-version` and `package.json`; none of them was ever +read, and `beplus.estate.json` is now the only place a repository pins its CLI. ## [v0.9.0] (10/02/2026) diff --git a/bin/be b/bin/be index ed296ad..14e284a 100755 --- a/bin/be +++ b/bin/be @@ -64,7 +64,7 @@ function n_grep() { # Setup and state # -VERSION="v0.9.0" +VERSION="v0.10.0" BE_PREFIX="${BE_PREFIX-/usr/local}" BE_PREFIX=${BE_PREFIX%/} @@ -278,6 +278,7 @@ Commands: be Display downloaded beplus CLI versions and install selection be latest Install the latest beplus CLI release (downloading if necessary) + be auto Install the beplus CLI version the nearest beplus.estate.json pins be Install beplus CLI (downloading if necessary) be install Install beplus CLI (downloading if necessary) be run [args ...] Execute downloaded beplus CLI with [args ...] @@ -316,7 +317,8 @@ Versions: 0.17.0, 1, v1.2 Numeric versions latest, current Newest official release - auto Read version from file: .bepluscloud, .beplus-version, or package.json + auto The exact version pinned as "cli": { "version": "x.y.z" } in the nearest + beplus.estate.json (in the current directory or above it); fails without one EOF } @@ -897,138 +899,109 @@ function tarball_url() { } # -# Synopsis: get_file_node_version filename -# Sets g_target_beplus +# Synopsis: display_estate_manifest_path +# Output the path of the nearest beplus.estate.json: in PWD, or else in the closest directory above +# it. Fails when there is none. # -function get_file_node_version() { - g_target_beplus= - local filepath="$1" - verbose_log "found" "${filepath}" - # read returns a non-zero status but does still work if there is no line ending - local version - <"${filepath}" read -r version - # trim possible trailing \d from a Windows created file - version="${version%%[[:space:]]}" - verbose_log "read" "${version}" - g_target_beplus="${version}" +function display_estate_manifest_path() { + local dir="${PWD}" + while true; do + if [[ -f "${dir%/}/beplus.estate.json" ]]; then + echo "${dir%/}/beplus.estate.json" + return 0 + fi + [[ -z "${dir}" || "${dir}" == "/" ]] && return 1 + dir="${dir%/*}" + done } # -# Synopsis: get_package_engine_version\ -# Sets g_target_beplus -# - -function get_package_engine_version() { - g_target_beplus= - local filepath="$1" - verbose_log "found" "${filepath}" - command -v node &> /dev/null || abort "an active version of node is required to read 'engines' from package.json" - local range - range="$(node -e "package = require('${filepath}'); if (package && package.engines && package.engines.node) console.log(package.engines.node)")" - verbose_log "read" "${range}" - [[ -n "${range}" ]] || return 2 - if [[ "*" == "${range}" ]]; then - verbose_log "target" "current" - g_target_beplus="current" - return - fi - - local version - if [[ "${range}" =~ ^([>~^=]|\>\=)?v?([0-9]+(\.[0-9]+){0,2})(.[xX*])?$ ]]; then - local operator="${BASH_REMATCH[1]}" - version="${BASH_REMATCH[2]}" - case "${operator}" in - '' | =) ;; - \> | \>=) version="current" ;; - \~) [[ "${version}" =~ ^([0-9]+\.[0-9]+)\.[0-9]+$ ]] && version="${BASH_REMATCH[1]}" ;; - ^) [[ "${version}" =~ ^([0-9]+) ]] && version="${BASH_REMATCH[1]}" ;; - esac - verbose_log "target" "${version}" +# Synopsis: display_estate_cli_version node|jq manifest +# Output the manifest's "cli": { "version" } as JSON, on one line: a string when the pin is one, +# null when the manifest has none. Fails, with the reason on stderr, when the file is not exactly +# one JSON value. +# + +function display_estate_cli_version() { + if [[ "$1" == "node" ]]; then + node -e ' + const has = (value, key) => + value !== null && typeof value === "object" && !Array.isArray(value) + && Object.prototype.hasOwnProperty.call(value, key); + let manifest; + try { + manifest = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + } catch (error) { + console.error(error.message); + process.exit(1); + } + const version = has(manifest, "cli") && has(manifest.cli, "version") ? manifest.cli.version : null; + console.log(JSON.stringify(version)); + ' "$2" else - command -v npx &> /dev/null || abort "an active version of npx is required to use complex 'engine' ranges from package.json" - verbose_log "resolving" "${range}" - local version_per_line="$(be lsr --all)" - local versions_one_line=$(echo "${version_per_line}" | tr '\n' ' ') - # Using semver@7 so works with older versions of node. - # shellcheck disable=SC2086 - version=$(npm_config_yes=true npx --quiet semver@7 -r "${range}" ${versions_one_line} | tail -n 1) + # --slurp so that an empty file, or one holding several values, fails as JSON.parse does. + jq -r --slurp ' + if length != 1 then error("expected one JSON value, found \(length)") + else .[0] + | if type == "object" and (.cli | type) == "object" and (.cli | has("version")) + then .cli.version + else null + end + | tojson + end + ' "$2" fi - g_target_beplus="${version}" } # -# Synopsis: get_nvmrc_version +# Synopsis: get_auto_version +# Resolve auto to the beplus CLI version the nearest beplus.estate.json pins as +# "cli": { "version": "x.y.z" }. The pin must be one exact version (a leading v and a pre-release +# are allowed, as everywhere in be); a missing, floating or unreadable pin aborts, naming the file, +# rather than falling back to another version. The nearest manifest decides, even when it pins +# nothing: one further up is never consulted. # Sets g_target_beplus # -function get_nvmrc_version() { +function get_auto_version() { g_target_beplus= - local filepath="$1" - verbose_log "found" "${filepath}" - local version - <"${filepath}" read -r version - verbose_log "read" "${version}" - # Translate from nvm aliases - case "${version}" in - lts/\*) version="lts" ;; - lts/*) version="${version:4}" ;; - node) version="current" ;; - *) ;; - esac - g_target_beplus="${version}" -} + local manifest + manifest="$(display_estate_manifest_path)" \ + || abort "auto found no beplus.estate.json in ${PWD} or above it, so no beplus CLI version is pinned" + verbose_log "found" "${manifest}" -# -# Synopsis: get_engine_version [error-message] -# Sets g_target_beplus -# + # node is on every deploy path; jq is what be's remote lookups use. + local reader + if command -v node &> /dev/null; then + reader="node" + elif command -v jq &> /dev/null; then + reader="jq" + else + abort "reading the beplus CLI pin from ${manifest} needs node or jq, and neither is on PATH" + fi -function get_engine_version() { - g_target_beplus= - local error_message="${1-package.json not found}" - local parent - parent="${PWD}" - while [[ -n "${parent}" ]]; do - if [[ -e "${parent}/package.json" ]]; then - get_package_engine_version "${parent}/package.json" - else - parent=${parent%/*} - continue - fi - break - done - [[ -n "${parent}" ]] || abort "${error_message}" - [[ -n "${g_target_beplus}" ]] || abort "did not find supported version of node in 'engines' field of package.json" -} + local value + if ! value="$(display_estate_cli_version "${reader}" "${manifest}" 2> /dev/null)"; then + abort "${manifest} could not be read as JSON by ${reader}: $(display_estate_cli_version "${reader}" "${manifest}" 2>&1 > /dev/null)" + fi -# -# Synopsis: get_auto_version -# Sets g_target_beplus -# + local version + case "${value}" in + null) + abort "${manifest} pins no beplus CLI version; add \"cli\": { \"version\": \"x.y.z\" }" ;; + \"*\") + version="${value#\"}" + version="${version%\"}" + ;; + *) + abort "\"cli\": { \"version\" } in ${manifest} is ${value}, not a version string like \"2.11.0\"" ;; + esac + verbose_log "read" "${version}" -# @todo -function get_auto_version() { - g_target_beplus= - # Search for a version control file first - local parent - parent="${PWD}" - while [[ -n "${parent}" ]]; do - if [[ -e "${parent}/.n-node-version" ]]; then - get_file_node_version "${parent}/.n-node-version" - elif [[ -e "${parent}/.node-version" ]]; then - get_file_node_version "${parent}/.node-version" - elif [[ -e "${parent}/.nvmrc" ]]; then - get_nvmrc_version "${parent}/.nvmrc" - else - parent=${parent%/*} - continue - fi - break - done - # Fallback to package.json - [[ -n "${parent}" ]] || get_engine_version "no file found for auto version (.n-node-version, .node-version, .nvmrc, or package.json)" - [[ -n "${g_target_beplus}" ]] || abort "file found for auto did not contain target version of node" + is_exact_numeric_version "${version}" \ + || abort "\"cli\": { \"version\" } in ${manifest} is \"${version}\", not one exact version like \"2.11.0\"" + g_target_beplus="${version#v}" } # @@ -1039,6 +1012,12 @@ function get_auto_version() { function get_latest_resolved_version() { g_target_beplus= local version=${1} + if [[ "${version}" == "auto" ]]; then + # The pin is one exact version, so there is nothing to look up: auto installs exactly that + # version or fails, and never reads the release index (where "newest" lives). + get_auto_version + return + fi simple_version=${version} if is_exact_numeric_version "${simple_version}"; then # Just numbers, already resolved, no need to lookup first. diff --git a/package-lock.json b/package-lock.json index c2194d9..8bd2ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@beplus/be", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@beplus/be", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "os": [ "!win32" diff --git a/package.json b/package.json index 691b8b0..e5c1354 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@beplus/be", "description": "Interactively Manage beplus CLI Versions", - "version": "0.9.0", + "version": "0.10.0", "author": "Igor Lamos ", "homepage": "https://github.com/beplus/be", "bugs": "https://github.com/beplus/be", diff --git a/test/tests/auto.bats b/test/tests/auto.bats new file mode 100644 index 0000000..0db3fcd --- /dev/null +++ b/test/tests/auto.bats @@ -0,0 +1,190 @@ +#!/usr/bin/env bats +# +# `be auto` installs exactly the beplus CLI version a repository pins in its beplus.estate.json, +# as "cli": { "version": "x.y.z" }, so that a CLI release cannot change what a deploy runs without +# a commit in that repository. Without a pin it fails, naming the file, rather than falling back to +# the newest release. These run against a local mirror and release index where 2.12.0 is newer +# than the pinned 2.11.0. + +load shared-functions +load '../../node_modules/bats-support/load' +load '../../node_modules/bats-assert/load' + + +function setup() { + unset_n_env + setup_tmp_prefix + setup_tmp_mirror 2.11.0 2.12.0 + cat > "${TMP_MIRROR_DIR}/releases.json" <<'JSON' +[ + {"name": "v2.12.0", "tag_name": "v2.12.0", "prerelease": false, "draft": false}, + {"name": "v2.11.0", "tag_name": "v2.11.0", "prerelease": false, "draft": false} +] +JSON + export BE_RELEASE_INDEX_URL="file://${TMP_MIRROR_DIR}/releases.json" + + PROJECT="${TMP_PREFIX_DIR}/estate" + mkdir -p "${PROJECT}/packages/app/src" + cd "${PROJECT}" || exit 2 +} + + +function teardown() { + rm -rf "${TMP_PREFIX_DIR}" "${TMP_MIRROR_DIR}" "${TMP_SHIM_DIR}" +} + + +# Synopsis: write_manifest [dir] +function write_manifest() { + printf '{\n "name": "estate",\n "cli": { "version": %s }\n}\n' "$1" > "${2:-${PROJECT}}/beplus.estate.json" +} + + +# Synopsis: setup_shim command... +# A directory holding be, bash and the given commands, and nothing else, to run be with as its PATH. +function setup_shim() { + TMP_SHIM_DIR="$(mktemp -d)" + ln -s "$(command -v be)" "${TMP_SHIM_DIR}/be" + ln -s "$(command -v bash)" "${TMP_SHIM_DIR}/bash" + local command + for command in "$@"; do + ln -s "$(command -v "${command}")" "${TMP_SHIM_DIR}/${command}" + done +} + + +@test "be auto installs exactly the version beplus.estate.json pins, not the newest" { + write_manifest '"2.11.0"' + cd "${PROJECT}/packages/app/src" + # The index this runs against offers a newer release. + assert_equal "$(be --latest)" "2.12.0" + + run be auto + assert_success + assert_output --partial "found : ${PROJECT}/beplus.estate.json" + assert_equal "$(beplus --version | cli_version)" "2.11.0" + assert_equal "$(be ls)" "beplus/2.11.0" + assert_equal "$(be which auto 2> /dev/null)" "${BE_PREFIX}/be/versions/beplus/2.11.0/beplus" + assert_equal "$(be ls-remote auto 2> /dev/null)" "2.11.0" +} + + +@test "the nearest beplus.estate.json decides" { + write_manifest '"2.12.0"' + write_manifest '"2.11.0"' "${PROJECT}/packages/app" + cd "${PROJECT}/packages/app/src" + + assert_equal "$(be N_TEST_DISPLAY_LATEST_RESOLVED_VERSION auto 2> /dev/null)" "2.11.0" +} + + +@test "a pin with a leading v, or a pre-release, is still one exact version" { + write_manifest '"v2.11.0"' + assert_equal "$(be N_TEST_DISPLAY_LATEST_RESOLVED_VERSION auto 2> /dev/null)" "2.11.0" + + write_manifest '"2.12.0-beta.1"' + assert_equal "$(be N_TEST_DISPLAY_LATEST_RESOLVED_VERSION auto 2> /dev/null)" "2.12.0-beta.1" +} + + +@test "be auto without a beplus.estate.json fails and installs nothing" { + run be auto + assert_failure + assert_output --partial "auto found no beplus.estate.json in ${PROJECT} or above it" + [ ! -e "${BE_PREFIX}/bin/beplus" ] + [ ! -e "${BE_PREFIX}/be/versions" ] +} + + +@test "be auto fails, naming the file, when beplus.estate.json pins no version" { + local manifest + for manifest in '{ "name": "estate" }' '{ "cli": {} }' '{ "cli": { "version": null } }'; do + echo "${manifest}" > "${PROJECT}/beplus.estate.json" + run be auto + assert_failure + assert_output --partial "${PROJECT}/beplus.estate.json pins no beplus CLI version" + done + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "a nearer beplus.estate.json without a pin does not fall back to one further up" { + write_manifest '"2.11.0"' + echo '{ "name": "app" }' > "${PROJECT}/packages/app/beplus.estate.json" + cd "${PROJECT}/packages/app" + + run be auto + assert_failure + assert_output --partial "${PROJECT}/packages/app/beplus.estate.json pins no beplus CLI version" + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "be auto refuses a pin that is not one exact version" { + local version + for version in '"2"' '"2.11"' '"latest"' '"^2.11.0"' '"~2.11.0"' '">=2.11.0"' '"2.11.x"' '""'; do + write_manifest "${version}" + run be auto + assert_failure + assert_output --partial "in ${PROJECT}/beplus.estate.json is ${version}, not one exact version" + done + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "be auto refuses a pin that is not a string" { + local version + for version in '2.11' 'true' '{"major":2}'; do + write_manifest "${version}" + run be auto + assert_failure + assert_output --partial "in ${PROJECT}/beplus.estate.json is ${version}, not a version string" + done + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "be auto fails, naming the file, when beplus.estate.json is not valid JSON" { + local manifest + for manifest in '{ "cli": { "version": "2.11.0" }' '' '{} {}'; do + printf '%s' "${manifest}" > "${PROJECT}/beplus.estate.json" + run be auto + assert_failure + assert_output --partial "${PROJECT}/beplus.estate.json could not be read as JSON by " + done + [ ! -e "${BE_PREFIX}/bin/beplus" ] +} + + +@test "jq reads the pin where there is no node" { + command -v jq > /dev/null || skip "jq is not installed" + setup_shim jq + + write_manifest '"2.11.0"' + run env PATH="${TMP_SHIM_DIR}" "${TMP_SHIM_DIR}/be" N_TEST_DISPLAY_LATEST_RESOLVED_VERSION auto + assert_success + assert_line "2.11.0" + + echo '{ "name": "estate" }' > "${PROJECT}/beplus.estate.json" + run env PATH="${TMP_SHIM_DIR}" "${TMP_SHIM_DIR}/be" auto + assert_failure + assert_output --partial "${PROJECT}/beplus.estate.json pins no beplus CLI version" + + local manifest + for manifest in '{ "cli": { "version": "2.11.0" }' '' '{} {}'; do + printf '%s' "${manifest}" > "${PROJECT}/beplus.estate.json" + run env PATH="${TMP_SHIM_DIR}" "${TMP_SHIM_DIR}/be" auto + assert_failure + assert_output --partial "${PROJECT}/beplus.estate.json could not be read as JSON by jq: " + done +} + + +@test "be auto without node or jq says it needs one of them" { + setup_shim + + write_manifest '"2.11.0"' + run env PATH="${TMP_SHIM_DIR}" "${TMP_SHIM_DIR}/be" auto + assert_failure + assert_output --partial "reading the beplus CLI pin from ${PROJECT}/beplus.estate.json needs node or jq" +} diff --git a/test/tests/shared-functions.bash b/test/tests/shared-functions.bash index 2a18844..067301e 100644 --- a/test/tests/shared-functions.bash +++ b/test/tests/shared-functions.bash @@ -36,31 +36,34 @@ function setup_tmp_prefix() { # Create a local mirror laid out as the beplus CLI release publishes one, and point be at it: -# v/ holds this machine's tarball, with a stub beplus that prints the version banner, -# and SHA256SUMS beside it. The mirror is a file:// URL, which be reads with curl only (wget -# fetches http, https and ftp), so without curl the test is skipped. +# for each given, v/ holds this machine's tarball, with a stub beplus that +# prints the version banner, and SHA256SUMS beside it. The mirror is a file:// URL, which be reads +# with curl only (wget fetches http, https and ftp), so without curl the test is skipped. +# Usage: setup_tmp_mirror version... # Globals: # TMP_MIRROR_DIR # BE_MIRROR function setup_tmp_mirror() { command -v curl > /dev/null || skip "the local mirror is a file:// URL, which needs curl" - local version="$1" TMP_MIRROR_DIR="$(mktemp -d)" [ -d "${TMP_MIRROR_DIR}" ] || exit 2 export TMP_MIRROR_DIR - local release="${TMP_MIRROR_DIR}/v${version}" - local tarball="beplus-cli-v${version}-$(tarball_platform).tar.gz" - mkdir -p "${release}" "${TMP_MIRROR_DIR}/stub" - printf '#!/bin/sh\necho "beplus CLI v%s"\n' "${version}" > "${TMP_MIRROR_DIR}/stub/beplus" - chmod +x "${TMP_MIRROR_DIR}/stub/beplus" - tar -czf "${release}/${tarball}" -C "${TMP_MIRROR_DIR}/stub" beplus - if command -v sha256sum &> /dev/null; then - (cd "${release}" && sha256sum "${tarball}" > SHA256SUMS) - else - (cd "${release}" && shasum -a 256 "${tarball}" > SHA256SUMS) - fi + local version + for version in "$@"; do + local release="${TMP_MIRROR_DIR}/v${version}" + local tarball="beplus-cli-v${version}-$(tarball_platform).tar.gz" + mkdir -p "${release}" "${TMP_MIRROR_DIR}/stub" + printf '#!/bin/sh\necho "beplus CLI v%s"\n' "${version}" > "${TMP_MIRROR_DIR}/stub/beplus" + chmod +x "${TMP_MIRROR_DIR}/stub/beplus" + tar -czf "${release}/${tarball}" -C "${TMP_MIRROR_DIR}/stub" beplus + if command -v sha256sum &> /dev/null; then + (cd "${release}" && sha256sum "${tarball}" > SHA256SUMS) + else + (cd "${release}" && shasum -a 256 "${tarball}" > SHA256SUMS) + fi + done export BE_MIRROR="file://${TMP_MIRROR_DIR}" }