mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
Merge remote-tracking branch 'origin/main' into capy/fixwave-5
This commit is contained in:
commit
f71a7de114
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);
|
||||
}
|
||||
@@ -625,6 +625,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