mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
86091dd7f5
commit
83f15043ac
16 files changed
+421
-25
No files matched your search
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user