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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-17 00:41:41 +00:00
1 parent 86091dd7f5
commit 83f15043ac
16 files changed
+421 -25

No files matched your search

+20
View File
@@ -1,5 +1,25 @@
# Changelog
## [1.91.110.0] - 2026-10-16
**A full install now ends ok on gbrain: the installer resolves and installs the project's pinned Node, and the launch PATH no longer puts a shared bin directory first.** On 1.91.107.0 a full install in gbrain still ended `gstack: fail pins`. gbrain pins node 24.18.0 in CI (`.github/workflows/native-locks.yml`), the Capy image ships 24.21.0 in `/usr/local/bin`, and the installer handled only Bun pins (GBRA-117). Changing nvm's default didn't help, because the launch PATH put the resolved Bun's directory first. When that Bun was `/usr/local/bin/bun`, the image's node came with it (GBRA-122).
### What this means for you
- **The project's Node pin is resolved like its Bun pin.** `engines.node`, `.nvmrc`, `.tool-versions` and CI `node-version:` pins are read by the same parser. Non-CI exact pins are required, and CI pins in different jobs are alternatives. The installer takes the first node that satisfies them, in this order: the node first on PATH, gstack's store copy of a wanted version, nvm's copy, then any other node on PATH. With no match, install mode downloads the pinned release from nodejs.org into `<state root>/runtimes/node/<version>`, checked against `SHASUMS256.txt`. `/usr/local/bin` and nvm are never written, and `--check` never installs.
- **The install verdict records the Node** (`node_path`, `node_version`, `node_rule`), and `--check` keeps using it while it still reports its version and satisfies the pins.
- **The launch PATH never exposes a shared bin directory.** A Bun from gstack's store still launches from its own version directory. Any other Bun, and a Node the launch needs, launch through a gstack-owned directory beside the verdict that holds only `bun`, `bunx` and, when needed, `node`, `npm`, `npx` and `corepack` symlinks. The pregate's lanes use the same directory.
- **A Node pin mismatch says what to do.** The doctor's `pins` row names the node it read and gives a fix: on Capy, the installer command that installs the pinned Node; elsewhere, `nvm install <version>` and putting it first on PATH.
### If you…
- **…run gstack on a Capy machine in a repo that pins Node:** run `bash <skill>/scripts/install.sh --for <skill>` once, then launch with the `GSTACK_LAUNCH` line it prints.
### For contributors
- `bin/gstack-node-runtime.sh` holds Node resolution, the verified install and the launch-directory writer. `renderPinLines` now prints `PIN_EXACT_REQUIRED` and `PIN_CI_ALTERNATIVES` lines for node as well as bun.
- `test/gstack-capy-install.test.ts` covers a Bun resolving into a directory that holds another node, gbrain's CI pin served from nvm and recorded, and a store install with a SHA-256 mismatch control. The doctor, runtime-pins and pregate launch tests cover the fix line, the node plan lines and the recorded launch directory.
## [1.91.109.0] - 2026-10-16
**Pregate's secrets check stops passing real keys it never scanned, Windows Git Bash can run `gstack-upgrade` again, and four user-reported workflow bugs are fixed.** The secrets check could miss a committed AWS key in two ways and flagged ordinary TypeScript as secrets. `gstack-upgrade` refused every Git Bash install. Catalog field names failed a check that can't be downgraded. Browse treated some server-side writes as read-only, and a plan review could delete another review's summary. Each fix has a test that fails on the previous release.
+1 -1
View File
@@ -1 +1 @@
1.91.109.0
1.91.110.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.109.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.110.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+60 -4
View File
@@ -99,6 +99,7 @@ else
fi
. "$ROOT/bin/gstack-bun-version.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-bun-version.sh is missing" "re-clone gstack"
. "$ROOT/bin/gstack-install-verdict.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-install-verdict.sh is missing" "re-clone gstack"
. "$ROOT/bin/gstack-node-runtime.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-node-runtime.sh is missing" "re-clone gstack"
# 2. Preflight: tools, then Bun through the one resolution contract
# (gstack_bun_resolve: the project's exact pin and gstack's install default in
@@ -291,9 +292,64 @@ if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_BUN" ]; then
export GSTACK_DOCTOR_RUNTIME_DRIFT_FIX="$_fix"
fi
fi
if [ -n "$RES_BIN" ] && [ "$(gstack_realpath "$(command -v bun 2>/dev/null || echo /nonexistent)" 2>/dev/null)" != "$RES_BIN" ]; then
LAUNCH_DIR=$(dirname "$RES_BIN")
export PATH="$LAUNCH_DIR:$PATH"
# Node: the project's exact Node pins resolve, and install into the runtime
# store, like Bun's (bin/gstack-node-runtime.sh): non-CI exact pins are
# required, CI exact pins in different jobs are alternatives. Parser mode
# only. --check keeps the recorded Node while it still reports its version and
# satisfies the pins, and never installs.
NODE_STORE=$(gstack_node_store "$GSTACK_STATE_ROOT")
NODE_BIN="" NODE_VER="" NODE_RULE="" NODE_WANT=""
node_accept() { ! "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --node "$1" 2>/dev/null | grep -q '^PIN_FAIL: node '; }
node_pins_text() { printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^node' | tr '\n' ';' | sed 's/;$//; s/;/; /g'; }
if [ "$PINS_MODE" = parser ] && printf '%s\n' "$PINS_OUT" | grep -q '^PIN: node '; then
_nreq=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: node \([^ ]*\) \([^ ]*\)$/\1 \2/p')
[ "$(printf '%s\n' "$_nreq" | grep -c .)" -gt 1 ] \
&& stop RUNTIME_PIN_CONFLICT "Node conflict: $PROJECT pins more than one exact Node ($(printf '%s\n' "$_nreq" | sed 's/^\([^ ]*\) \(.*\)$/\2 pins \1/' | tr '\n' ';' | sed 's/;$//; s/;/; /g'))" "make the project's exact Node pins agree"
if [ -n "$_nreq" ]; then NODE_WANT=${_nreq%% *}
else NODE_WANT=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_CI_ALTERNATIVES: node //p' | tr ' ' '\n' | grep . | sort -t. -k1,1nr -k2,2nr -k3,3nr | tr '\n' ' ' | sed 's/ $//')
fi
REC_NODE="" REC_NODE_VER=""
[ -f "$VERDICT" ] && REC_NODE=$(gstack_install_verdict_field "$VERDICT" node_path) REC_NODE_VER=$(gstack_install_verdict_field "$VERDICT" node_version)
if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_NODE" ] && [ "$(gstack_node_version "$REC_NODE")" = "$REC_NODE_VER" ] && node_accept "$REC_NODE_VER"; then
NODE_BIN="$REC_NODE" NODE_VER="$REC_NODE_VER" NODE_RULE=$(gstack_install_verdict_field "$VERDICT" node_rule)
elif gstack_node_resolve "$NODE_STORE" "$NODE_WANT" node_accept; then
NODE_BIN="$_gn_bin" NODE_VER="$_gn_ver" NODE_RULE="$_gn_rule"
elif [ "$CHECK_ONLY" -eq 0 ] && [ -n "$NODE_WANT" ]; then
_nv=${NODE_WANT%% *}
say "no installed Node satisfies the project's Node pins $(node_pins_text) (node on PATH: $(gstack_node_version "$(command -v node 2>/dev/null || echo /nonexistent)" || echo none)); installing node $_nv from its release archive (SHA-256 checked against SHASUMS256.txt) into $NODE_STORE/$_nv"
exec 8>"$HOME/.gstack-runtime.lock"
if command -v flock >/dev/null 2>&1; then flock -w 300 8 || stop INSTALL_PREFLIGHT_FAILED "another install holds $HOME/.gstack-runtime.lock" "wait for it to finish"; fi
_nerr=$(gstack_node_install "$NODE_STORE" "$_nv") || stop INSTALL_PREFLIGHT_FAILED "node $_nv: $_nerr" "check network access to nodejs.org, then re-run; or install node $_nv yourself (nvm install $_nv) and re-run"
exec 8>&-
gstack_node_resolve "$NODE_STORE" "$_nv" node_accept \
|| stop RUNTIME_PIN_CONFLICT "Node conflict: node $_nv in $NODE_STORE does not satisfy $PROJECT's Node pins $(node_pins_text)" "make the project's Node pins agree"
NODE_BIN="$_gn_bin" NODE_VER="$_gn_ver" NODE_RULE="$_gn_rule"
else
say "note: no installed Node satisfies the project's Node pins $(node_pins_text); gstack-doctor's pins row names the fix"
fi
[ -n "$NODE_BIN" ] && say "node $NODE_VER at $NODE_BIN ($NODE_RULE; satisfies the project's Node pins $(node_pins_text))"
fi
# The launch PATH. gstack's store holds one Bun per directory, so a store Bun
# launches from its own directory. Any other resolved Bun, and a resolved Node
# that PATH does not already give, launch through a gstack-owned directory of
# symlinks beside the verdict, so a shared bin directory (/usr/local/bin with
# the image's node, $BUN_INSTALL/bin) never lands in front of PATH.
if [ -n "$RES_BIN" ]; then
_launch_node=""
[ -n "$NODE_BIN" ] && [ "$(gstack_realpath "$(command -v node 2>/dev/null || echo /nonexistent)" 2>/dev/null)" != "$NODE_BIN" ] && _launch_node="$NODE_BIN"
if [ -n "$_launch_node" ] || [ "$(gstack_realpath "$(command -v bun 2>/dev/null || echo /nonexistent)" 2>/dev/null)" != "$RES_BIN" ]; then
_store_real=$(cd -P "$STORE" 2>/dev/null && pwd -P)
case "$RES_BIN" in
"${_store_real:-/nonexistent}"/*/bun) [ -z "$_launch_node" ] && LAUNCH_DIR=$(dirname "$RES_BIN") ;;
esac
if [ -z "$LAUNCH_DIR" ]; then
LAUNCH_DIR="${VERDICT%.json}.launch"
gstack_launch_dir "$LAUNCH_DIR" "$RES_BIN" "$_launch_node" \
|| stop INSTALL_PREFLIGHT_FAILED "could not publish the launch directory $LAUNCH_DIR" "make $(dirname "$LAUNCH_DIR") writable, then re-run"
fi
export PATH="$LAUNCH_DIR:$PATH"
fi
fi
# 3. Install both host profiles; Chromium lazy unless --with-browser. Skipped
@@ -314,7 +370,7 @@ record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift
else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi
gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "${FOR:-}" "$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)" \
"$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \
"$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" \
"$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" \
|| say "could not write the install verdict $VERDICT"
}
setup_failed() { # MESSAGE
+13
View File
@@ -319,6 +319,19 @@ _dc_check_pins() {
fi
local fix="install the pinned version for this platform (bin/gstack-capy-install installs gstack's verified Bun alongside when a pin names it), or change the pin in $project" note=""
[ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ] && fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (installs the project's pinned Bun when it is a verified version: $GSTACK_BUN_VERIFIED; otherwise it names the conflict), or change the pin in $project"
if printf '%s\n' "$out" | grep -q '^PIN_FAIL: node '; then
local nwant
nwant=$(printf '%s\n' "$out" | sed -n 's/^PIN_EXACT_REQUIRED: node \([^ ]*\) .*/\1/p' | head -1)
[ -n "$nwant" ] || nwant=$(printf '%s\n' "$out" | sed -n 's/^PIN_CI_ALTERNATIVES: node //p' | tr ' ' '\n' | grep . | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -1)
note="; node read from $(command -v node 2>/dev/null || echo 'nowhere (no node on PATH)')"
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (installs the project's pinned Node${nwant:+ $nwant} into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in $project"
elif [ -n "$nwant" ]; then
fix="nvm install $nwant and put its bin directory first on PATH (the Capy skill installer installs it into gstack's runtime store instead), or change the pin in $project"
else
fix="install a Node that satisfies the project's Node pins and put it first on PATH, or change the pin in $project"
fi
fi
if [ -n "$_dc_ln_dir" ] && [ "$_dc_ln_ver" != "$_dc_bun" ] \
&& bun "$BIN/gstack-runtime-pins.ts" --project "$project" --bun "$_dc_ln_ver" ${node:+--node "$node"} </dev/null 2>/dev/null | grep -q '^PINS_VERDICT: pass'; then
note="; bun $_dc_ln_ver found at $_dc_ln_dir (not on PATH) satisfies them" fix="$_dc_ln_fix"
+6 -3
View File
@@ -10,7 +10,9 @@
# written atomically by bin/gstack-capy-install (install and --check) with
# schema_version 2, checkout, head, for, required[], pins_planning_only, ok,
# trailer, min_revision, launch_path, time, project, pin (the exact Bun pin),
# bun_path (realpath), bun_version, bun_rule (gstack_bun_resolve), bun_install
# bun_path (realpath), bun_version, bun_rule (gstack_bun_resolve), bun_install,
# node_path (realpath), node_version, node_rule (gstack_node_resolve; empty
# when the project pins no Node)
# and named in the GSTACK_LAUNCH line as GSTACK_INSTALL_VERDICT=<path>. It is
# the one record the bun shim, lib/pregate/runtime.ts and admission read.
# Readers fall back to the schema 1 file keyed by the checkout alone,
@@ -67,7 +69,7 @@ gstack_install_project() {
_gstack_iv_json() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\000-\037')"; }
# gstack_install_verdict_write FILE CHECKOUT HEAD FOR REQUIRED(space list) PINS_PLANNING_ONLY(0|1) OK(0|1) TRAILER MIN LAUNCH_PATH
# PROJECT PIN BUN_PATH BUN_VERSION BUN_RULE BUN_INSTALL
# PROJECT PIN BUN_PATH BUN_VERSION BUN_RULE BUN_INSTALL [NODE_PATH NODE_VERSION NODE_RULE]
gstack_install_verdict_write() {
local file="$1" tmp id req="" first=1
for id in $5; do
@@ -82,7 +84,8 @@ gstack_install_verdict_write() {
printf ' "required": [%s],\n "pins_planning_only": %s,\n "ok": %s,\n' "$req" "$([ "$6" = 1 ] && echo true || echo false)" "$([ "$7" = 1 ] && echo true || echo false)"
printf ' "trailer": %s,\n "min_revision": %s,\n "launch_path": %s,\n' "$(_gstack_iv_json "$8")" "$(_gstack_iv_json "$9")" "$(_gstack_iv_json "${10}")"
printf ' "project": %s,\n "pin": %s,\n "bun_path": %s,\n "bun_version": %s,\n' "$(_gstack_iv_json "${11:-}")" "$(_gstack_iv_json "${12:-}")" "$(_gstack_iv_json "${13:-}")" "$(_gstack_iv_json "${14:-}")"
printf ' "bun_rule": %s,\n "bun_install": %s,\n "time": %s\n}\n' "$(_gstack_iv_json "${15:-}")" "$(_gstack_iv_json "${16:-}")" "$(_gstack_iv_json "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
printf ' "bun_rule": %s,\n "bun_install": %s,\n' "$(_gstack_iv_json "${15:-}")" "$(_gstack_iv_json "${16:-}")"
printf ' "node_path": %s,\n "node_version": %s,\n "node_rule": %s,\n "time": %s\n}\n' "$(_gstack_iv_json "${17:-}")" "$(_gstack_iv_json "${18:-}")" "$(_gstack_iv_json "${19:-}")" "$(_gstack_iv_json "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
} > "$tmp" && mv -f "$tmp" "$file"
}
+120
View File
@@ -0,0 +1,120 @@
# 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"
}
+32 -4
View File
@@ -121,6 +121,26 @@ whose pid is alive and outside its own process chain exists;
constraints ([RUNTIME_PIN_CONFLICT](troubleshooting.md#capy-install-runtime-conflict),
exit 3) before `./setup`. With no Bun at or above the security floor, the
default is installed first so the pins can be read.
Node follows the same rule (`bin/gstack-node-runtime.sh`): `engines.node`,
`.nvmrc`, `.tool-versions` and CI `node-version:` lanes, non-CI exact pins
required and CI exact pins in different jobs alternatives. The first node
that satisfies them wins: the node first on PATH (rule `PATH`), then for
each wanted version gstack's store copy
`<state root>/runtimes/node/<version>/bin/node` (`store`) and nvm's
`${NVM_DIR:-~/.nvm}/versions/node/v<version>/bin/node` (`nvm`), then every
other `node` on PATH. When nothing qualifies and the project names an exact
version, install mode downloads `node-v<version>-<target>.tar.gz` and
`SHASUMS256.txt` from `https://nodejs.org/dist/v<version>/`
(`GSTACK_NODE_DIST_URL` overrides the base), checks the archive's SHA-256
against that list and publishes the tree in the store by rename. A CI-only
pin set installs its highest version. `/usr/local/bin` and nvm are never
written. Two different non-CI exact Node pins stop with
[RUNTIME_PIN_CONFLICT](troubleshooting.md#capy-install-runtime-conflict).
`--check` keeps the recorded Node while it still reports its version and
satisfies the pins, and never installs one; the doctor's `pins` row reads
whatever node the launch PATH gives, and a Node mismatch names the node it
read and the installer command that fixes it.
3. `./setup --host claude --no-prefix`, then `./setup --host codex --model
gpt-6-astra`, both with `--no-browser`. Skipped when this exact HEAD was set
up with the same hosts, model and browser flag (recorded in the git dir
@@ -135,7 +155,8 @@ whose pid is alive and outside its own process chain exists;
checkout, project, HEAD, the workflow and required components it
certified, whether pins were skipped as planning-only, the pin it
honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and
`bun_install` when this run downloaded it), the trailer, the remote
`bun_install` when this run downloaded it), the resolved Node when the
project pins one (`node_path`, `node_version`, `node_rule`), the trailer, the remote
minimum, the launch PATH and the time. Admission refuses a workflow whose
running Bun is not the one the verdict recorded
([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so
@@ -252,9 +273,16 @@ GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT
GSTACK_LAUNCH_PATH: <dir>
```
`PATH=<dir>:$PATH` and `GSTACK_LAUNCH_PATH` appear only when the certified Bun
is not first on PATH (the Capy image's `/usr/local/bin/bun` is 1.3.14; 57 gstack
commands start with `#!/usr/bin/env bun`). A child shell's `export` cannot
`PATH=<dir>:$PATH` and `GSTACK_LAUNCH_PATH` appear only when the certified Bun,
or the project's resolved Node, is not first on PATH (older Capy images ship
`/usr/local/bin/bun` 1.3.14; 57 gstack commands start with `#!/usr/bin/env bun`).
`<dir>` is never a shared bin directory: a Bun from gstack's store launches
from its own version directory, and any other Bun, or a Node the launch
needs, launches through `<verdict file without .json>.launch`, a gstack-owned
directory holding only `bun`, `bunx` and, when needed, `node`, `npm`, `npx` and
`corepack` symlinks. So a Bun found in `/usr/local/bin` or `$BUN_INSTALL/bin`
never puts that directory's other tools (the image's node) in front of PATH.
The pregate's lanes launch through the same directory. A child shell's `export` cannot
configure the parent's later commands, so the parent supplies these
assignments to every gstack command it launches (prefix the command, or
export them once in the shell that runs it). A fresh shell without them keeps
+2 -1
View File
@@ -181,7 +181,8 @@ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchR
const now = fs.existsSync(recorded!.bun_path) ? bunVersionOf(recorded!.bun_path, env) : '';
const why = !now ? 'is gone' : now !== recorded!.bun_version ? `now reports ${now}` : !accept(now) ? 'no longer satisfies the project pins' : '';
if (why) return refuse('BUN_RESOLUTION_DRIFT', `recorded bun ${recorded!.bun_version} at ${recorded!.bun_path} ${why}`, 're-run the Capy skill installer with --check (scripts/install.sh --check --for ship) and follow its fix');
return result(path.dirname(recorded!.bun_path), now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', '');
const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path);
return result(launch, now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', '');
}
}
const { resolution } = resolveBun({ store: bunStore(resolveStateRoot(env)), env, pinExact, accept });
+5 -3
View File
@@ -259,9 +259,11 @@ export function renderPinLines(report: PinReport, platform: NodeJS.Platform = pr
for (const s of report.summary) lines.push(`PIN_SUMMARY: ${s}`);
for (const s of report.skipped) lines.push(`PIN_SKIP: ${s}`);
for (const f of report.failures) lines.push(`PIN_FAIL: ${f}`);
const plan = exactPlan(report.pins, 'bun', platform);
for (const r of plan.required) lines.push(`PIN_EXACT_REQUIRED: bun ${r.version} ${r.source}`);
if (plan.ciAlternatives.length) lines.push(`PIN_CI_ALTERNATIVES: bun ${plan.ciAlternatives.join(' ')}`);
for (const tool of ['bun', 'node'] as const) {
const plan = exactPlan(report.pins, tool, platform);
for (const r of plan.required) lines.push(`PIN_EXACT_REQUIRED: ${tool} ${r.version} ${r.source}`);
if (plan.ciAlternatives.length) lines.push(`PIN_CI_ALTERNATIVES: ${tool} ${plan.ciAlternatives.join(' ')}`);
}
lines.push(`PINS_VERDICT: ${report.verdict}`);
return lines;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.109",
"version": "1.91.110",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+7
View File
@@ -130,5 +130,12 @@ describe('one Bun resolution contract, two twins', () => {
expect(r.refusal).toBeNull();
expect(r.verdict).toBe('ok');
expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`);
expect(r.dir).toBe(path.dirname(REAL_BUN));
const launch = path.join(l.state, 'install-verdicts', 'x.launch');
fs.mkdirSync(launch, { recursive: true });
fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), launch_path: launch }));
const viaLaunch = launchRuntime(ROOT, l.env);
expect(viaLaunch.dir).toBe(launch);
expect(viaLaunch.env.PATH?.split(path.delimiter)[0]).toBe(launch);
});
});
+2
View File
@@ -140,6 +140,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
'user-invoked installer: bodyless HEAD reachability probe to github.com + clone of the public gbrain repo (user-directed install; no gstack state leaves the machine)',
'bin/gstack-capy-install':
'user-invoked installer that runs before any gstack checkout exists (so the egress lib is not yet available): clone of the public gstack repo and a download of the pinned Bun release archive from GitHub, SHA-256 verified before anything is installed; no gstack state leaves the machine',
'bin/gstack-node-runtime.sh':
'sourced only by bin/gstack-capy-install (same pre-checkout installer context): downloads the project-pinned Node release archive and its SHASUMS256.txt from nodejs.org, SHA-256 checked before anything is installed; bodyless GETs, no gstack state leaves the machine',
'bin/gstack-doctor-check.sh':
'git fetch appears only in the revision row\'s fix string (the command the user runs to move the checkout)',
'bin/gstack-next-version':
+122 -7
View File
@@ -48,7 +48,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
const log = path.join(base, 'calls.log');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(stub, { recursive: true });
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh']) {
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode);
@@ -56,7 +56,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
write(path.join(root, 'VERSION'), '1.2.3\n');
script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "gstack ready ($2)"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']);
script(path.join(root, 'bin/gstack-doctor'), [
'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"',
'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"', 'echo "doctor-node $(node --version 2>/dev/null)" >> "$CAPY_LOG"',
'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"',
'[ "${STUB_DOCTOR_FAIL:-}" = 1 ] && { echo "FAIL codex — missing; fix: x (COMPONENT_MISSING; d)"; echo "gstack: fail codex"; exit 1; }',
'[ -n "${GSTACK_DOCTOR_RUNTIME_DRIFT:-}" ] && { echo "FAIL runtime — bun resolution drifted: $GSTACK_DOCTOR_RUNTIME_DRIFT; fix: $GSTACK_DOCTOR_RUNTIME_DRIFT_FIX (BUN_RESOLUTION_DRIFT; d)"; echo "gstack: fail runtime"; exit 1; }',
@@ -67,7 +67,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
sh(`git init -q && git add -A && git -c user.email=t@t -c user.name=t commit -qm init`, root);
script(path.join(stub, 'bun'), [`[ "$1" = --version ] && { echo "${opts.bun ?? '1.4.2'}"; exit 0; }`, 'exit 0']);
script(path.join(stub, 'node'), ['[ "$1" = --version ] && { echo v24.18.0; exit 0; }', 'exit 0']);
script(path.join(stub, 'curl'), ['echo "curl $*" >> "$CAPY_LOG"', 'out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done', '[ -n "${STUB_CURL_ZIP:-}" ] && cp "$STUB_CURL_ZIP" "$out" && exit 0', 'exit 22']);
script(path.join(stub, 'curl'), ['echo "curl $*" >> "$CAPY_LOG"', 'out=""; url=""; while [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift ;; http*|file:*) url="$1" ;; esac; shift; done', '[ -n "${STUB_CURL_DIR:-}" ] && [ -f "$STUB_CURL_DIR/${url##*/}" ] && cp "$STUB_CURL_DIR/${url##*/}" "$out" && exit 0', '[ -n "${STUB_CURL_ZIP:-}" ] && cp "$STUB_CURL_ZIP" "$out" && exit 0', 'exit 22']);
if (opts.jq !== false) {
const real = sh('command -v jq').stdout.trim();
if (real) fs.symlinkSync(real, path.join(stub, 'jq'));
@@ -425,6 +425,14 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
return zip;
};
const verdictFor = (f: ReturnType<typeof fixture>, r: { stdout: string }) => JSON.parse(fs.readFileSync(r.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1], 'utf8'));
/** A Bun outside the store launches through the gstack-owned directory beside the verdict, holding only bun and bunx. */
const expectLaunch = (r: { stdout: string }, bun: string) => {
const dir = r.stdout.match(/GSTACK_LAUNCH_PATH: (\S+)/)?.[1] ?? '';
expect(dir).toBe(r.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)\.json/)![1] + '.launch');
expect(r.stdout).toContain(`GSTACK_LAUNCH: PATH=${dir}:$PATH `);
expect(fs.readdirSync(dir).sort()).toEqual(['bun', 'bunx']);
expect(fs.realpathSync(path.join(dir, 'bun'))).toBe(bun);
};
test('GBRA-60: 1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin with a CI-lane pin of 1.4.3 — install and --check agree on ~/.bun/bin/bun, nothing is downloaded or overwritten', () => {
const f = fixture({ bun: '1.3.14' });
@@ -442,10 +450,10 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
expect(verdictFor(f, r)).toMatchObject({ project, pin: '1.4.3', bun_path: `${f.home}/.bun/bin/bun`, bun_version: '1.4.3', bun_rule: 'home' });
const c = run(f, ['--check', '--for', 'review'], env, project);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(c.stdout).toContain(`GSTACK_LAUNCH: PATH=${f.home}/.bun/bin:$PATH `);
expectLaunch(c, `${f.home}/.bun/bin/bun`);
const outside = run(f, ['--check', '--for', 'autoplan'], env, f.base);
expect(outside.status, outside.stdout + outside.stderr).toBe(0);
expect(outside.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `);
expectLaunch(outside, `${bi}/bin/bun`);
});
// Integration item 1: gstack's own CI pins 1.4.2 on every lane and 1.4.3 on
@@ -545,12 +553,12 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0);
const unset = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: '' });
expect(unset.status, unset.stdout + unset.stderr).toBe(0);
expect(unset.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `);
expectLaunch(unset, `${bi}/bin/bun`);
realBun(path.join(store(f, '1.4.3'), 'bun'), '1.4.3');
const newer = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi });
expect(newer.status, newer.stdout + newer.stderr).toBe(0);
expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun 1.4.3 at ${store(f, '1.4.3')}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`);
expect(newer.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `);
expectLaunch(newer, `${bi}/bin/bun`);
expect(newer.stdout).not.toContain('BUN_RESOLUTION_DRIFT');
expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0);
const recorded = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi });
@@ -759,6 +767,113 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
});
});
// GBRA-117 / GBRA-122: a project's Node pin resolves and installs like its Bun
// pin, and the launch PATH never puts a shared bin directory (with another
// node in it) in front of PATH.
describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the project Node pin and the launch directory', () => {
const realBun = (file: string, version: string) => script(file, [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]);
const fakeNode = (file: string, version: string) => script(file, [`echo v${version}`]);
const withPins = (f: ReturnType<typeof fixture>) => { for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(f.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755); };
const nodeProject = (f: ReturnType<typeof fixture>, nodePin: string) => {
const project = path.join(f.base, 'gbrain');
write(path.join(project, 'package.json'), '{ "engines": { "bun": ">=1.4.0" } }\n');
write(path.join(project, '.github/workflows/native-locks.yml'), `jobs:\n locks:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version: ${nodePin}\n`);
sh('git init -q', project);
return project;
};
const launchOf = (r: { stdout: string }) => r.stdout.match(/GSTACK_LAUNCH_PATH: (\S+)/)?.[1] ?? '';
const nodeSeen = (f: ReturnType<typeof fixture>) => f.calls().filter(l => l.startsWith('doctor-node ')).at(-1);
const nodeTarget = () => (sh('uname -m').stdout.trim() === 'x86_64' ? 'linux-x64' : 'linux-arm64');
test('Bun resolving into a directory that also holds another node launches through a bun-only directory, so the doctor reads PATH\'s node', () => {
const f = fixture({ bun: '1.3.14' });
const bi = path.join(f.base, 'bun-install');
realBun(path.join(bi, 'bin/bun'), '1.4.2');
fakeNode(path.join(bi, 'bin/node'), '20.0.0');
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi });
expect(r.status, r.stdout + r.stderr).toBe(0);
const dir = launchOf(r);
expect(dir).toBe(r.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)\.json/)![1] + '.launch');
expect(r.stdout).toContain(`GSTACK_LAUNCH: PATH=${dir}:$PATH `);
expect(r.stdout).not.toContain(`PATH=${bi}/bin:`);
expect(fs.readdirSync(dir).sort()).toEqual(['bun', 'bunx']);
expect(fs.realpathSync(path.join(dir, 'bun'))).toBe(path.join(bi, 'bin/bun'));
expect(nodeSeen(f)).toBe('doctor-node v24.18.0');
});
test('gbrain\'s CI Node pin 24.18.0 with node 24.21.0 first on PATH: nvm\'s 24.18.0 is launched and recorded, and --check reuses it', () => {
const f = fixture();
withPins(f);
realBun(path.join(f.stub, 'bun'), '1.4.2');
fakeNode(path.join(f.stub, 'node'), '24.21.0');
const nvmNode = path.join(f.home, '.nvm/versions/node/v24.18.0/bin/node');
fakeNode(nvmNode, '24.18.0');
const project = nodeProject(f, '24.18.0');
const r = run(f, ['--for', 'review'], {}, project);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain(`node 24.18.0 at ${nvmNode} (nvm; satisfies the project's Node pins node 24.18.0 (CI linux lane, native-locks.yml:7))`);
const dir = launchOf(r);
expect(fs.readdirSync(dir).sort()).toEqual(['bun', 'bunx', 'node']);
expect(fs.realpathSync(path.join(dir, 'node'))).toBe(nvmNode);
expect(fs.realpathSync(path.join(dir, 'bun'))).toBe(path.join(f.stub, 'bun'));
expect(nodeSeen(f)).toBe('doctor-node v24.18.0');
expect(f.calls().some(l => l.startsWith('curl '))).toBe(false);
const verdict = JSON.parse(fs.readFileSync(r.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1], 'utf8'));
expect(verdict).toMatchObject({ node_path: nvmNode, node_version: '24.18.0', node_rule: 'nvm', launch_path: dir });
const c = run(f, ['--check', '--for', 'review'], {}, project);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(launchOf(c)).toBe(dir);
expect(nodeSeen(f)).toBe('doctor-node v24.18.0');
fakeNode(path.join(f.stub, 'node'), '24.18.0');
const kept = run(f, ['--check', '--for', 'review'], {}, project);
expect(launchOf(kept)).toBe(dir);
const onPath = run(f, ['--for', 'review'], {}, project);
expect(onPath.status, onPath.stdout + onPath.stderr).toBe(0);
expect(onPath.stdout).not.toContain('GSTACK_LAUNCH_PATH');
expect(JSON.parse(fs.readFileSync(onPath.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1], 'utf8'))).toMatchObject({ node_path: path.join(f.stub, 'node'), node_rule: 'PATH', launch_path: '' });
});
test('no installed Node satisfies the pin: --check installs nothing; install downloads it into the runtime store checked against SHASUMS256.txt; a mismatch installs nothing', () => {
const f = fixture();
withPins(f);
realBun(path.join(f.stub, 'bun'), '1.4.2');
fakeNode(path.join(f.stub, 'node'), '24.21.0');
const project = nodeProject(f, '24.18.0');
const dist = path.join(f.base, 'dist');
const name = `node-v24.18.0-${nodeTarget()}`;
fakeNode(path.join(f.base, 'tree', name, 'bin/node'), '24.18.0');
fs.mkdirSync(dist, { recursive: true });
expect(sh(`tar -czf "${dist}/${name}.tar.gz" -C "${f.base}/tree" ${name}`).status).toBe(0);
const sum = sh(`sha256sum "${dist}/${name}.tar.gz" | cut -d' ' -f1`).stdout.trim();
write(path.join(dist, 'SHASUMS256.txt'), `${'0'.repeat(64)} node-v24.18.0-other.tar.gz\n${sum} ${name}.tar.gz\n`);
const nodeStore = path.join(f.home, '.gstack-state/runtimes/node/24.18.0');
run(f, ['--check', '--for', 'review'], { STUB_CURL_DIR: dist }, project);
expect(f.calls().some(l => l.startsWith('curl '))).toBe(false);
expect(fs.existsSync(nodeStore)).toBe(false);
write(path.join(dist, 'SHASUMS256.txt'), `${'1'.repeat(64)} ${name}.tar.gz\n`);
const bad = run(f, ['--for', 'review'], { STUB_CURL_DIR: dist }, project);
expect(bad.status).toBe(3);
expect(bad.stderr).toContain(`${name}.tar.gz SHA-256 ${sum} does not match SHASUMS256.txt (${'1'.repeat(64)}); nothing installed`);
expect(bad.stderr).toContain('(INSTALL_PREFLIGHT_FAILED; ');
expect(fs.existsSync(nodeStore)).toBe(false);
write(path.join(dist, 'SHASUMS256.txt'), `${sum} ${name}.tar.gz\n`);
const r = run(f, ['--for', 'review'], { STUB_CURL_DIR: dist }, project);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('curl ')).slice(-2).map(l => l.split(' ').find(a => a.startsWith('https://')))).toEqual([
'https://nodejs.org/dist/v24.18.0/SHASUMS256.txt', `https://nodejs.org/dist/v24.18.0/${name}.tar.gz`,
]);
expect(r.stdout).toContain(`node 24.18.0 at ${nodeStore}/bin/node (store; `);
expect(fs.realpathSync(path.join(launchOf(r), 'node'))).toBe(path.join(nodeStore, 'bin/node'));
expect(nodeSeen(f)).toBe('doctor-node v24.18.0');
const calls = f.calls().filter(l => l.startsWith('curl ')).length;
expect(run(f, ['--for', 'review'], { STUB_CURL_DIR: dist }, project).status).toBe(0);
expect(f.calls().filter(l => l.startsWith('curl ')).length).toBe(calls);
});
});
function run(f: ReturnType<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) {
return f.run(args, extra, cwd);
}
+17
View File
@@ -624,6 +624,23 @@ describe('gstack-doctor --check', () => {
expect(plain.rows.pins.rest).not.toContain(wrapper);
});
test('project pins: a Node pin mismatch names the node it read and a fix that installs the pinned Node', () => {
const f = makeFixture();
const project = path.join(f.base, 'project');
write(path.join(project, '.github/workflows/native-locks.yml'), 'jobs:\n locks:\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version: 24.18.0\n');
script(path.join(f.stub, 'node'), ['echo v24.21.0']);
const wrapper = '/drive/skills/gstack/scripts/install.sh';
const capy = row(f, ['--project', project, '--for', 'review'], { GSTACK_CAPY_WRAPPER_PATH: wrapper });
expect(capy.rows.pins.status).toBe('FAIL');
expect(capy.rows.pins.rest).toContain(`node 24.21.0 outside CI 24.18.0 (.github/workflows/native-locks.yml:6); node read from ${bashPath(f.stub)}/node`);
expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} --for review (installs the project's pinned Node 24.18.0 into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in ${project}`);
expect(capy.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; ');
const plain = row(f, ['--project', project, '--for', 'review']);
expect(plain.rows.pins.rest).toContain(`fix: nvm install 24.18.0 and put its bin directory first on PATH`);
script(path.join(f.stub, 'node'), ['echo v24.18.0']);
expect(row(f, ['--project', project, '--for', 'review']).rows.pins.status).toBe('PASS');
});
test('--revision: a matching VERSION passes, an unmet revision is never ok', () => {
const f = makeFixture();
const ok = row(f, ['--revision', VERSION]);
+12
View File
@@ -182,6 +182,18 @@ describe('CI exact pins are alternatives', () => {
expect(lines).toContain('PIN_CI_ALTERNATIVES: bun 1.4.3');
expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').verdict).toBe('fail');
});
test('Node pins render the same plan lines as Bun: .nvmrc required, CI node-version pins in different jobs alternatives', () => {
const dir = project({
'.nvmrc': '24.18.0\n',
'.github/workflows/ci.yml': 'jobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - with:\n node-version: 24.18.0\n b:\n runs-on: ubuntu-latest\n steps:\n - with:\n node-version: 22.20.0\n',
});
const lines = renderPinLines(evaluatePins(collectPins(dir), { node: '24.18.0' }, 'linux'), 'linux');
expect(lines).toContain('PIN_EXACT_REQUIRED: node 24.18.0 .nvmrc:1');
expect(lines).toContain('PIN_CI_ALTERNATIVES: node 24.18.0 22.20.0');
expect(lines.at(-1)).toBe('PINS_VERDICT: pass');
expect(evaluatePins(collectPins(dir), { node: '24.21.0' }, 'linux').failures).toEqual([expect.stringMatching(/^node 24\.21\.0 outside \.nvmrc 24\.18\.0/)]);
});
});
describe('bin/gstack-runtime-pins.ts', () => {