diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 71948eb16214..60c45fc12a35 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -4,7 +4,7 @@ # without waiting for CI to publish a tagged release. # # Usage: -# bash scripts/install-local.sh [version-label] +# bash scripts/install-local.sh [version-label] [--ahead-ok] [--skip-smoke] # # Version label # ------------- @@ -32,6 +32,19 @@ # "you're building something CI hasn't released, the DB is now ahead of brew" # trap. Pass `--ahead-ok` to suppress the warning when intentional. # +# Smoke-test (pre-install verification) +# ------------------------------------- +# Before stomping the Cellar binary we exercise the freshly-built binary with +# two cheap commands: +# 1. `--version` — must exit 0 with a parseable version string +# 2. `debug info` — must exit 0 with a clean stderr (no TypeError / +# ReferenceError / SyntaxError / fatal-error / U.length) +# If either fails, the install aborts and the working brew binary stays put. +# Catches the class of regression where the build succeeds at the bundler +# layer but the produced binary crashes at module-load / plugin-resolution / +# config-read — surfaces caught 2026-05-27 (PR #14-17 era) that previously +# would have gone live silently. Pass `--skip-smoke` to override. +# # Pairs with the standard release flow: tag a release for distribution, but # install locally immediately so Nik isn't blocked on CI for his own bin. @@ -41,12 +54,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # --- parse args -------------------------------------------------------------- AHEAD_OK=0 +SKIP_SMOKE=0 EXPLICIT_LABEL="" for arg in "$@"; do case "$arg" in --ahead-ok) AHEAD_OK=1 ;; + --skip-smoke) SKIP_SMOKE=1 ;; -h|--help) - sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,50p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; *) EXPLICIT_LABEL="$arg" ;; @@ -128,6 +143,82 @@ if [ -z "$SRC_BIN" ] || [ ! -x "$SRC_BIN" ]; then exit 1 fi +# --- smoke-test -------------------------------------------------------------- +# Verify the new binary actually loads + executes a short command path before +# we stomp the working brew Cellar binary. Catches the class of regression +# where a JS-level crash (e.g. U.length on undefined) is silently produced by +# the build but only surfaces when a NEW tab tries to mount the TUI — by which +# time the old binary is already gone and Nik has no working gruntcode. +# +# Two layers, both cheap: +# 1. --version catches module-load + top-level import crashes +# 2. debug info catches plugin-resolution + config-load regressions +# (touches the same module graph the TUI mount does) +# +# Pass --skip-smoke for emergencies (rebuild loop on a known-broken state +# where you intentionally want the new binary installed despite a smoke fail). +if [ "$SKIP_SMOKE" -eq 1 ]; then + echo "→ Smoke-test SKIPPED (--skip-smoke)" +else + echo "→ Smoke-testing new binary at $SRC_BIN..." + SMOKE_LOG=$(mktemp -t gruntcode-smoke.XXXXXX) + SMOKE_FAILED=0 + + # `timeout` is a GNU coreutils binary; on macOS it's `gtimeout` (via `brew + # install coreutils`) or not present at all. Both --version and `debug info` + # exit in <1s on a healthy binary, so a hang here would indicate a real + # regression worth catching — but we only insist on the wrapper if it's + # available. On macOS without coreutils we run unwrapped + rely on the + # commands being fast. + if command -v timeout >/dev/null 2>&1; then + TIMEOUT_BIN="timeout 10" + elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_BIN="gtimeout 10" + else + TIMEOUT_BIN="" + fi + + # Layer 1: --version. Exits 0 immediately if module-load succeeds. + if ! $TIMEOUT_BIN "$SRC_BIN" --version >"$SMOKE_LOG" 2>&1; then + SMOKE_FAILED=1 + echo " ✗ \`--version\` failed" >&2 + else + SMOKE_VERSION=$(cat "$SMOKE_LOG") + echo " ✓ --version → $SMOKE_VERSION" + fi + + # Layer 2: debug info. Exercises plugin-resolution + config load. + if [ "$SMOKE_FAILED" -eq 0 ]; then + if ! $TIMEOUT_BIN "$SRC_BIN" debug info >"$SMOKE_LOG" 2>&1; then + SMOKE_FAILED=1 + echo " ✗ \`debug info\` failed" >&2 + elif grep -qE '(TypeError|ReferenceError|SyntaxError|fatal error|Cannot read prop|U\.length)' "$SMOKE_LOG"; then + # Catch JS-level crash markers that may print on otherwise-zero-exit + # paths — e.g. an unhandled rejection in a plugin load can still produce + # exit 0 but with `TypeError` on stderr. + SMOKE_FAILED=1 + echo " ✗ \`debug info\` exited 0 but stderr looks crashy:" >&2 + sed 's/^/ /' "$SMOKE_LOG" >&2 + else + echo " ✓ debug info clean" + fi + fi + + if [ "$SMOKE_FAILED" -ne 0 ]; then + echo >&2 + echo "✗ Smoke-test FAILED. Refusing to replace the Cellar binary." >&2 + echo " Build output preserved at: $SRC_BIN" >&2 + echo " Last smoke log:" >&2 + sed 's/^/ /' "$SMOKE_LOG" >&2 + echo >&2 + echo " Rebuild after fixing the regression, or pass --skip-smoke to override." >&2 + exit 1 + fi + + rm -f "$SMOKE_LOG" + echo " Smoke OK — proceeding to install." +fi + # --- install ----------------------------------------------------------------- CELLAR_BIN=$(find /opt/homebrew/Cellar/gruntcode -name gruntcode -type f 2>/dev/null | sort -r | head -1) if [ -z "$CELLAR_BIN" ]; then