fix(install): unblock the installer on macOS — BSD sed, and a deadline on Chromium

Two independent failures, both of which stopped `make plugin` outright here.

GNU `sed -i` takes no suffix argument; BSD sed requires one and reads the
script as that suffix, so all three calls errored — and under `set -euo
pipefail` that aborts the installer. They go through a temp file now, written
back with `cat >` so the profile keeps its mode and owner.

While rewriting them: the orphan-comment cleanup `{N; /^\n$/d;}` could never
match on ANY platform — after N the pattern space starts with '#', so the
^\n$ anchor pair never applied. It was a silent no-op, now awk, and tested on
/bin/bash 3.2: both obsolete entries and their stranded headers go, the live
entry and the user's own lines stay, idempotent on re-run.

The second failure was worse because it was invisible: gstack's ./setup runs
`bunx playwright install chromium` unbounded, and Playwright 1.58.2 deadlocks
on Node 26 mid-extraction — both processes idle at 39/333 files, no error, no
progress, forever. That silently cut the 2026-09-13 run at step 2 of 10.
Chromium is now installed here instead, under a 900s deadline; on timeout the
installer bumps gstack's Playwright (its package.json declares "^1.x", so a
minor bump is in range) and retries once, then warns rather than aborts —
gstack is OFF by default and only its browser depends on this.

`timeout` is not in a stock macOS, so the deadline is pure bash. Proven by a
forced hang: rc 124 in 6s, no orphaned oopDownloadBrowserMain, stderr clean.
This commit is contained in:
2026-09-13 17:21:23 -04:00
parent a53a5a26a8
commit 28211a78ea
+93 -5
View File
@@ -16,6 +16,16 @@ err() { echo -e "${RED}✗${NC} $1"; }
REPO="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "$(dirname "$0")" && pwd)"
# In-place file edit that works on both GNU and BSD sed: `sed -i` needs a backup
# suffix argument on macOS and forbids one on Linux, so go through a temp file
# instead. Writing back with `cat >` (not `mv`) keeps the original mode/owner.
_sed_inplace() {
local expr="$1" file="$2" tmp
tmp="$(mktemp)" || return 1
sed "$expr" "$file" > "$tmp" && cat "$tmp" > "$file"
rm -f "$tmp"
}
# Log to file for post-mortem debugging (terminal output unchanged) # Log to file for post-mortem debugging (terminal output unchanged)
LOG_FILE="$REPO/install-$(date +%Y%m%d-%H%M%S).log" LOG_FILE="$REPO/install-$(date +%Y%m%d-%H%M%S).log"
if touch "$LOG_FILE" 2>/dev/null; then if touch "$LOG_FILE" 2>/dev/null; then
@@ -291,6 +301,67 @@ fi
echo "" echo ""
# Portable `timeout`: GNU coreutils' timeout is absent from a stock macOS, and
# this has to run on a machine where nothing is installed yet. Returns 124 when
# the deadline is hit, otherwise the command's own exit status.
_run_with_timeout() {
local secs="$1" waited=0 pid
shift
"$@" &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$secs" ]; then
# Park the shell's own stderr: bash announces a signal-killed job on ITS
# stderr, so redirecting kill/wait alone does not suppress it — and that
# line in the install log reads like a real error.
exec 3>&2 2>/dev/null
kill -TERM "$pid" 2>/dev/null || true
# Playwright downloads in a child process that survives a TERM aimed at
# its parent; left alone it keeps holding the browser-cache lock.
pkill -f oopDownloadBrowserMain 2>/dev/null || true
wait "$pid" 2>/dev/null || true
exec 2>&3 3>&-
return 124
fi
sleep 5
waited=$((waited + 5))
done
wait "$pid"
}
# Populate gstack's node_modules at the locked versions so `bunx playwright`
# resolves the LOCAL Playwright. Without it bunx pulls the latest from npm,
# which wants a different browser revision than the one gstack imports.
_gstack_bun_install() {
( cd "$GSTACK_DIR" && { bun install --frozen-lockfile >/dev/null 2>&1 ||
bun install >/dev/null 2>&1; } )
}
_gstack_pw_install() { ( cd "$GSTACK_DIR" && bunx playwright install chromium ); }
# Install gstack's Chromium here instead of leaving it to ./setup, so the
# download can be bounded. A Playwright older than the running Node deadlocks
# mid-extraction — both processes idle, no error, no progress, forever — which
# silently stalls the whole installer. On a timeout, bump Playwright (gstack
# declares "playwright": "^1.x", so a minor bump is within its own range) and
# retry once. A second failure warns rather than aborts: gstack is OFF by
# default and only its browser (/browse, /qa, screenshots) depends on this.
gstack_install_browser_guarded() {
[ -d "$GSTACK_DIR" ] && command -v bun >/dev/null 2>&1 || return 0
_gstack_bun_install || return 0
if _run_with_timeout "$GSTACK_BROWSER_TIMEOUT" _gstack_pw_install; then
ok "gstack Chromium ready"
return 0
fi
warn "Chromium install stalled >${GSTACK_BROWSER_TIMEOUT}s — bumping gstack's Playwright, retrying"
( cd "$GSTACK_DIR" && bun add playwright@latest >/dev/null 2>&1 ) || true
if _run_with_timeout "$GSTACK_BROWSER_TIMEOUT" _gstack_pw_install; then
ok "gstack Chromium ready after Playwright bump (./setup rebuilds browse)"
else
warn "gstack Chromium unavailable — /browse, /qa and screenshots stay off"
fi
}
# gstack pins Playwright (1.58.x) which only ships browser builds for # gstack pins Playwright (1.58.x) which only ships browser builds for
# ubuntu<=24.04. On a newer distro the browser install fails ("does not # ubuntu<=24.04. On a newer distro the browser install fails ("does not
# support chromium on ubuntuXX.04"). Bump gstack's Playwright to a version # support chromium on ubuntuXX.04"). Bump gstack's Playwright to a version
@@ -307,7 +378,7 @@ gstack_bump_playwright_if_unsupported() {
[ -n "$ostag" ] || return 0 # only the known Ubuntu case [ -n "$ostag" ] || return 0 # only the known Ubuntu case
pwlib="$GSTACK_DIR/node_modules/playwright-core/lib" pwlib="$GSTACK_DIR/node_modules/playwright-core/lib"
# populate node_modules at the pinned version so we can read its support list # populate node_modules at the pinned version so we can read its support list
( cd "$GSTACK_DIR" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null 2>&1; } ) || return 0 _gstack_bun_install || return 0
if grep -rqs "$ostag" "$pwlib" 2>/dev/null; then if grep -rqs "$ostag" "$pwlib" 2>/dev/null; then
return 0 # pinned Playwright already supports this OS return 0 # pinned Playwright already supports this OS
fi fi
@@ -337,6 +408,10 @@ echo ""
# git add skills-external/gstack && git commit -m "chore: update gstack" # git add skills-external/gstack && git commit -m "chore: update gstack"
GSTACK_DIR="$REPO/skills-external/gstack" GSTACK_DIR="$REPO/skills-external/gstack"
# Deadline for gstack's Chromium download+extract. Generous on purpose: a real
# install runs 1-3 min, overshooting only delays the fallback, and the failure
# it catches would otherwise hang the installer forever.
GSTACK_BROWSER_TIMEOUT=900
if [ ! -d "$GSTACK_DIR/.git" ] && [ ! -f "$GSTACK_DIR/.git" ]; then if [ ! -d "$GSTACK_DIR/.git" ] && [ ! -f "$GSTACK_DIR/.git" ]; then
info "Initializing GStack submodule..." info "Initializing GStack submodule..."
@@ -369,6 +444,10 @@ if [ -d "$GSTACK_DIR" ]; then
# chromium" fail). Non-fatal if it can't — gstack is OFF by default. # chromium" fail). Non-fatal if it can't — gstack is OFF by default.
gstack_bump_playwright_if_unsupported gstack_bump_playwright_if_unsupported
# Then fetch the browser under a deadline. ./setup would do it itself, but
# unbounded — and this is the step that hangs when Playwright trails Node.
gstack_install_browser_guarded
info "Running GStack setup..." info "Running GStack setup..."
_gstack_setup_ok=0 _gstack_setup_ok=0
if [ -x "$GSTACK_DIR/setup" ]; then if [ -x "$GSTACK_DIR/setup" ]; then
@@ -993,14 +1072,23 @@ fi
# `claude --effort max` alias (the alias would even override settings.json). # `claude --effort max` alias (the alias would even override settings.json).
EFFORT_CLEANED=0 EFFORT_CLEANED=0
if grep -qF 'export CLAUDE_EFFORT=max' "$SHELL_PROFILE" 2>/dev/null; then if grep -qF 'export CLAUDE_EFFORT=max' "$SHELL_PROFILE" 2>/dev/null; then
sed -i '/export CLAUDE_EFFORT=max/d' "$SHELL_PROFILE"; EFFORT_CLEANED=1 _sed_inplace '/export CLAUDE_EFFORT=max/d' "$SHELL_PROFILE"; EFFORT_CLEANED=1
fi fi
if grep -qF "alias claude='claude --effort max'" "$SHELL_PROFILE" 2>/dev/null; then if grep -qF "alias claude='claude --effort max'" "$SHELL_PROFILE" 2>/dev/null; then
sed -i "\#alias claude='claude --effort max'#d" "$SHELL_PROFILE"; EFFORT_CLEANED=1 _sed_inplace "\#alias claude='claude --effort max'#d" "$SHELL_PROFILE"; EFFORT_CLEANED=1
fi fi
if [ "$EFFORT_CLEANED" -eq 1 ]; then if [ "$EFFORT_CLEANED" -eq 1 ]; then
# Remove orphaned comment lines left before the deleted entries # Drop the header comment left stranded above a deleted entry (the marker
sed -i '/^# Claude Code — added by install-plugins.sh$/{ N; /^\n$/d; }' "$SHELL_PROFILE" # followed by a blank line, or by end-of-file). awk, not sed: the previous
# `{N; /^\n$/d;}` could never match — after N the pattern space starts with
# '#', so the ^\n$ anchor pair never applied, and it was a silent no-op.
_tmp_profile="$(mktemp)"
awk -v marker='# Claude Code — added by install-plugins.sh' '
$0 == marker { stranded = 1; next }
stranded { stranded = 0; if ($0 == "") next; print marker }
{ print }
' "$SHELL_PROFILE" > "$_tmp_profile" && cat "$_tmp_profile" > "$SHELL_PROFILE"
rm -f "$_tmp_profile"
info "Removed obsolete effort alias/env from $SHELL_PROFILE (effort set in settings.json)" info "Removed obsolete effort alias/env from $SHELL_PROFILE (effort set in settings.json)"
fi fi