Files
gstack/bin/gstack-node-runtime.sh
T
Garry Tanandcapy-ai[bot] 83f15043ac v1.91.110.0 fix(capy): install the project's pinned Node; launch Bun through a bun-only directory (#3233)
* fix(capy): install and launch the project's pinned Node; launch Bun through a bun-only directory

gbrain pins node 24.18.0 in CI while the Capy image ships 24.21.0 in
/usr/local/bin, so a full install still ended with fail pins (GBRA-117).
The installer now resolves the project's exact Node pins the way it resolves
Bun's (required non-CI pins, CI pins in different jobs as alternatives): the
node first on PATH, then gstack's store copy and nvm's copy of each wanted
version, then any other node on PATH. With no match, install mode downloads the
pinned release into <state root>/runtimes/node and checks it against
SHASUMS256.txt. The resolution is recorded in the install verdict
(node_path, node_version, node_rule), and --check reuses it.

The launch PATH no longer prepends a shared bin directory. When the resolved
Bun was /usr/local/bin/bun, the image's node there shadowed every other node
(GBRA-122). A store Bun still launches from its own version directory; any
other Bun, and a Node the launch needs, launch through a gstack-owned
directory of symlinks beside the verdict. The pregate's lanes use the
recorded launch directory.

The doctor's pins row names the node it read and, for a Node mismatch, the
installer command that installs the pinned Node (or nvm install <v> off
Capy).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: v1.91.109.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(egress): exempt the Node installer's two release downloads with their reason

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: restamp v1.91.110.0 (1.91.109.0 is #3225's slot)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(doctor): make the Node pin case platform-neutral (an any-lane CI pin, bash's spelling of the stub path)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 00:41:41 +00:00

121 lines
5.7 KiB
Bash

# shellcheck shell=bash
# gstack-node-runtime.sh — the project's pinned Node, Bash side (sourced by
# bin/gstack-capy-install, never executed). Bash 3.2 plus POSIX tools; needs
# gstack-bun-version.sh sourced first (gstack_realpath).
#
# A project's exact Node pins come from lib/runtime-pins.ts (PIN_EXACT_REQUIRED
# and PIN_CI_ALTERNATIVES lines for node): non-CI exact pins are required, CI
# exact pins in different jobs are alternatives, the same rule as Bun.
# Resolution order, the first that satisfies the pins winning:
# 1. the node first on PATH (rule PATH: nothing to launch);
# 2. per wanted version, gstack's runtime store <state root>/runtimes/node/<v>/bin/node
# (rule store), then nvm's ${NVM_DIR:-~/.nvm}/versions/node/v<v>/bin/node (rule nvm);
# 3. every other node on PATH (rule PATH).
# Install downloads node-v<v>-<target>.tar.gz and SHASUMS256.txt from
# ${GSTACK_NODE_DIST_URL:-https://nodejs.org/dist}/v<v>/, checks the archive's
# SHA-256 against that list (nvm's own check) and publishes the tree in the
# store by rename. /usr/local/bin and nvm are never written.
gstack_node_store() { printf '%s/runtimes/node\n' "$1"; }
# gstack_node_version FILE — the version FILE reports, without the leading v.
gstack_node_version() {
local v
[ -x "$1" ] || return 1
v=$("$1" --version </dev/null 2>/dev/null | head -1 | tr -cd 'v0-9.')
v="${v#v}"
[ -n "$v" ] && printf '%s\n' "$v"
}
_gstack_node_try() { # RULE FILE ACCEPT [WANT]
local real ver
[ -x "$2" ] || return 1
real=$(gstack_realpath "$2") || return 1
ver=$(gstack_node_version "$real") || return 1
[ -z "${4:-}" ] || [ "$ver" = "$4" ] || return 1
"$3" "$ver" || return 1
_gn_bin="$real" _gn_ver="$ver" _gn_rule="$1"
}
# gstack_node_resolve STORE "WANTED VERSIONS" ACCEPT — sets _gn_bin (realpath),
# _gn_ver and _gn_rule (PATH|store|nvm); returns 1 when nothing qualifies.
# ACCEPT VERSION returns 0 when VERSION satisfies every project Node pin.
gstack_node_resolve() {
local store="$1" wanted="$2" accept="$3" v dir first nvm="${NVM_DIR:-$HOME/.nvm}" gifs
_gn_bin="" _gn_ver="" _gn_rule=""
first=$(command -v node 2>/dev/null || true)
[ -n "$first" ] && _gstack_node_try PATH "$first" "$accept" && return 0
for v in $wanted; do
_gstack_node_try store "$store/$v/bin/node" "$accept" "$v" && return 0
_gstack_node_try nvm "$nvm/versions/node/v$v/bin/node" "$accept" "$v" && return 0
done
gifs=$IFS; IFS=:
for dir in $PATH; do
IFS=$gifs
[ -n "$dir" ] && _gstack_node_try PATH "$dir/node" "$accept" && return 0
done
IFS=$gifs
return 1
}
# gstack_node_target — the release archive suffix for this machine.
gstack_node_target() {
case "$(uname -ms)" in
'Linux x86_64') echo linux-x64 ;;
'Linux aarch64'|'Linux arm64') echo linux-arm64 ;;
'Darwin x86_64') echo darwin-x64 ;;
'Darwin arm64') echo darwin-arm64 ;;
*) return 1 ;;
esac
}
# gstack_node_install STORE VERSION — download, verify and publish Node VERSION
# at STORE/VERSION; on failure prints one reason and returns 1.
gstack_node_install() {
local store="$1" v="$2" target base tmp want got
target=$(gstack_node_target) || { echo "no Node release archive for $(uname -ms)"; return 1; }
base="${GSTACK_NODE_DIST_URL:-https://nodejs.org/dist}/v$v"
[ -x "$store/$v/bin/node" ] && [ "$(gstack_node_version "$store/$v/bin/node")" = "$v" ] && return 0
mkdir -p "$store" || { echo "cannot create the runtime store $store"; return 1; }
tmp=$(mktemp -d "$store/.tmp.XXXXXX") || { echo "cannot create a temp dir in $store"; return 1; }
if ! curl --retry 3 -fsSL "$base/SHASUMS256.txt" -o "$tmp/SHASUMS256.txt" \
|| ! curl --retry 3 -fsSL "$base/node-v$v-$target.tar.gz" -o "$tmp/node.tar.gz"; then
rm -rf "$tmp"; echo "could not download node-v$v-$target.tar.gz or SHASUMS256.txt from $base"; return 1
fi
want=$(awk -v f="node-v$v-$target.tar.gz" '$2 == f { print $1 }' "$tmp/SHASUMS256.txt" | head -1)
if command -v sha256sum >/dev/null 2>&1; then got=$(sha256sum "$tmp/node.tar.gz" | cut -d' ' -f1)
else got=$(shasum -a 256 "$tmp/node.tar.gz" | cut -d' ' -f1); fi
if [ -z "$want" ] || [ "$want" != "$got" ]; then
rm -rf "$tmp"; echo "node-v$v-$target.tar.gz SHA-256 ${got:-unreadable} does not match SHASUMS256.txt (${want:-no entry}); nothing installed"; return 1
fi
mkdir "$tmp/pub" && tar -xzf "$tmp/node.tar.gz" -C "$tmp/pub" --strip-components 1 \
|| { rm -rf "$tmp"; echo "could not unpack node-v$v-$target.tar.gz"; return 1; }
got=$(gstack_node_version "$tmp/pub/bin/node")
[ "$got" = "$v" ] || { rm -rf "$tmp"; echo "the downloaded node reports ${got:-nothing}, expected $v; nothing installed"; return 1; }
rm -rf "$store/$v" && mv -T "$tmp/pub" "$store/$v" || { rm -rf "$tmp"; echo "could not publish $store/$v"; return 1; }
rm -rf "$tmp"
}
# gstack_launch_dir DIR BUN [NODE] — publish DIR holding only symlinks to BUN
# (as bun and bunx) and NODE with its npm, npx and corepack, so the launch
# PATH never exposes a shared bin directory's other tools. Each entry is
# renamed into place; entries for a dropped NODE are removed.
gstack_launch_dir() {
local dir="$1" bun="$2" node="${3:-}" name src tmp
mkdir -p "$dir" || return 1
tmp=$(mktemp -d "$dir.tmp.XXXXXX") || return 1
ln -s "$bun" "$tmp/bun" && ln -s "$bun" "$tmp/bunx" || { rm -rf "$tmp"; return 1; }
if [ -n "$node" ]; then
ln -s "$node" "$tmp/node" || { rm -rf "$tmp"; return 1; }
for name in npm npx corepack; do
src="$(dirname "$node")/$name"
{ [ -e "$src" ] || [ -L "$src" ]; } && ln -s "$src" "$tmp/$name"
done
fi
for name in bun bunx node npm npx corepack; do
if [ -L "$tmp/$name" ]; then mv -fT "$tmp/$name" "$dir/$name" || { rm -rf "$tmp"; return 1; }
else rm -f "$dir/$name"; fi
done
rm -rf "$tmp"
}