Files
claude_mac/lib/gstack-playwright.sh
T
Bastien Chanot 2cebecbb91 feat(gstack): share the Playwright bump, report the browser cache
Extract gstack_bump_playwright_if_unsupported from install-plugins.sh into
lib/gstack-playwright.sh and call it from update-all.sh too. A submodule
update no longer leaves the OS-support bump unapplied until the next
`make plugin` — that was BDR-029's open caveat.

The update helper never touches the submodule working tree: on failure it
prints git's own message and points at `make plugin`, and returns non-zero
so the existing `else warn` arm still handles it.

Add a read-only `Playwright browsers` section to doctor.sh: cache size,
which registered install requires each revision, and counts of unreferenced
directories and broken links. No pruning is written — Playwright's own
`install` already unions the required set across every registered install,
and all three installs here are live (rev 1228 for gstack + gsd-pi 1.61,
rev 1243 for gsd-pi 1.63).

Carries two latent-bug fixes from the moved code: the ostag capture exited
1 on every non-Ubuntu host and aborted the caller under inherited errexit,
and the bun calls had no timeout.
2026-09-15 14:57:31 +02:00

297 lines
12 KiB
Bash

#!/usr/bin/env bash
# ============================================================
# lib/gstack-playwright.sh — gstack's Playwright: OS-support bump +
# read-only browser-cache report.
#
# Sourced by: install-plugins.sh, update-all.sh, doctor.sh — all three run
# `set -euo pipefail`. gstack_bump_playwright_if_unsupported and
# gstack_browsers_report are called as BARE STATEMENTS under that inherited
# errexit, so they `return 0` on every path and every capture that could
# fail is guarded (`|| true` or an `if`), never a bare `&&`/`||`-less
# statement. gstack_submodule_update_with_bump is the ONE function allowed
# to return non-zero — callers use it ONLY as an `if` condition.
#
# No `set -euo pipefail` here (mirrors lib/detect-plugins.sh): a sourced
# lib must not change the caller's shell options.
#
# See BDR-029 (bump origin), BLK-008 (Chromium-unsupported-OS saga),
# LRN-040 (two-layer fix — this file is layer 1 only).
# ============================================================
_GSPW_GREEN='\033[0;32m'; _GSPW_YELLOW='\033[1;33m'; _GSPW_BLUE='\033[0;34m'
_GSPW_NC='\033[0m'
_gspw_ok() { echo -e " ${_GSPW_GREEN}✓${_GSPW_NC} $1"; }
_gspw_warn() { echo -e " ${_GSPW_YELLOW}⚠${_GSPW_NC} $1"; }
_gspw_info() { echo -e " ${_GSPW_BLUE}→${_GSPW_NC} $1"; }
# ── OS support ───────────────────────────────────────────────────────────
# gstack_pw_ostag [os_release_path] — "ubuntu<VERSION_ID>" on Ubuntu, empty
# otherwise. `|| true` on the capture: the reproduced bug had this exact
# line abort every non-Ubuntu host under inherited errexit.
gstack_pw_ostag() {
local path="${1:-/etc/os-release}" tag
[ -r "$path" ] || return 0
# shellcheck disable=SC1090
tag="$(. "$path" 2>/dev/null
[ "${ID:-}" = ubuntu ] && printf 'ubuntu%s' "${VERSION_ID:-}")" || true
if [ -n "$tag" ]; then
printf '%s' "$tag"
fi
return 0
}
# gstack_pw_supports <playwright_core_lib_dir> <ostag> — 0 supported, 1 not.
# Always called from an `if`/`&&` context, never as a bare statement.
gstack_pw_supports() {
local pwlib="$1" ostag="$2"
[ -n "$ostag" ] && [ -d "$pwlib" ] || return 1
grep -rqs "$ostag" "$pwlib" 2>/dev/null
}
# _gspw_run_timeout <dir> <cmd...> — runs <cmd> in <dir>, under `timeout 300`
# when available (absent on stock macOS). Exit 124 = the wrapped command was
# killed by the timeout. Callers MUST invoke this via `cmd || rc=$?` (never
# bare) so a non-zero exit never trips the caller's inherited errexit.
_gspw_run_timeout() {
local dir="$1"; shift
if command -v timeout >/dev/null 2>&1; then
( cd "$dir" && timeout 300 "$@" ) >/dev/null 2>&1
else
( cd "$dir" && "$@" ) >/dev/null 2>&1
fi
}
# _gspw_bump_install <gstack_dir> — populate node_modules at the pinned
# version so its support list can be read. 0 proceed, 1 give up silently
# (both installs failed, matches the pre-existing silent behavior), 2 give
# up loud (a timeout truncated node_modules — the support grep would then
# read a half-written tree).
_gspw_bump_install() {
local dir="$1" rc=0
_gspw_run_timeout "$dir" bun install --frozen-lockfile || rc=$?
if [ "$rc" -eq 0 ]; then
return 0
elif [ "$rc" -eq 124 ]; then
_gspw_warn "bun install timed out — skipping Playwright bump"
return 2
fi
rc=0
_gspw_run_timeout "$dir" bun install || rc=$?
if [ "$rc" -eq 0 ]; then
return 0
elif [ "$rc" -eq 124 ]; then
_gspw_warn "bun install timed out — skipping Playwright bump"
return 2
fi
return 1
}
# _gspw_bump_add_latest <gstack_dir> — 0 ran (support re-checked by caller
# regardless of bun's own exit code, exactly as the pre-existing code did),
# 2 timed out (node_modules left half-written — caller must NOT re-check).
_gspw_bump_add_latest() {
local dir="$1" rc=0
_gspw_run_timeout "$dir" bun add playwright@latest || rc=$?
if [ "$rc" -eq 124 ]; then
_gspw_warn "bun add playwright@latest timed out — skipping Playwright bump"
return 2
fi
return 0
}
# gstack_bump_playwright_if_unsupported <gstack_dir> — BDR-029: bump
# gstack's pinned Playwright when it lacks a build for this OS, so
# `./setup` rebuilds the browse binary against a version that has one.
# OS-gated, idempotent, non-fatal — `return 0` on every path.
gstack_bump_playwright_if_unsupported() {
local gstack_dir="$1" ostag pwlib rc=0
[ -d "$gstack_dir" ] && [ -r /etc/os-release ] || return 0
ostag="$(gstack_pw_ostag)"
[ -n "$ostag" ] || return 0
if ! command -v bun >/dev/null 2>&1; then
export PATH="$HOME/.bun/bin:$PATH"
fi
pwlib="$gstack_dir/node_modules/playwright-core/lib"
_gspw_info "checking gstack's Playwright OS support ($ostag)..."
_gspw_bump_install "$gstack_dir" || rc=$?
[ "$rc" -eq 0 ] || return 0
if gstack_pw_supports "$pwlib" "$ostag"; then
return 0
fi
_gspw_info "gstack's Playwright lacks $ostag support — bumping to \
latest (local submodule edit)..."
rc=0
_gspw_bump_add_latest "$gstack_dir" || rc=$?
[ "$rc" -eq 0 ] || return 0
if gstack_pw_supports "$pwlib" "$ostag"; then
_gspw_ok "gstack Playwright bumped — now supports $ostag (browse \
binary rebuilt by ./setup)"
else
_gspw_warn "Playwright bump didn't add $ostag support — gstack \
browser may stay unavailable"
fi
return 0
}
# ── Submodule update ──────────────────────────────────────────────────────
# gstack_submodule_update_with_bump <repo> [sub_path] — the ONE function
# allowed to return non-zero; callers use it ONLY as an `if` condition.
# Never touches the submodule working tree: on failure it prints git's own
# stderr verbatim (never parsed) and returns 1. On success it re-applies
# the bump (closes BDR-029's caveat: the bump used to survive only until
# the next `git submodule update`).
gstack_submodule_update_with_bump() {
local repo="$1" sub="${2:-skills-external/gstack}" err rc=0
err="$(git -C "$repo" submodule update --remote "$sub" 2>&1 >/dev/null)" \
|| rc=$?
if [ "$rc" -eq 0 ]; then
gstack_bump_playwright_if_unsupported "$repo/$sub"
return 0
fi
_gspw_warn "$err"
if [ -n "$(git -C "$repo/$sub" status --porcelain \
-- package.json bun.lock 2>/dev/null)" ]; then
_gspw_info "local Playwright bump (package.json/bun.lock) was not \
re-applied — re-run: make plugin"
fi
return 1
}
# ── Browsers report (read-only) ───────────────────────────────────────────
# _gspw_dir_name_parts <cache_dir_name> — prints "normalized_name revision"
# split on the LAST '-', mapping '_' -> '-' on the name (Playwright writes
# chromium_headless_shell-1228 on disk; browsers.json names it
# chromium-headless-shell).
_gspw_dir_name_parts() {
local rev="${1##*-}" name="${1%-*}"
printf '%s %s' "${name//_/-}" "$rev"
}
# _gspw_browser_referenced <playwright_core_path> <dir_name> — does that
# install require this cache directory (base revision or any
# revisionOverrides value)?
_gspw_browser_referenced() {
local json="$1/browsers.json" name rev
[ -r "$json" ] || return 1
read -r name rev <<< "$(_gspw_dir_name_parts "$2")"
awk -F'"' -v want_name="$name" -v want_rev="$rev" '
$2 == "name" { cur = $4; in_ov = 0 }
$2 == "revision" && !in_ov && cur == want_name && $4 == want_rev {
found = 1
}
$2 == "revisionOverrides" { in_ov = 1 }
in_ov && $2 != "revisionOverrides" && cur == want_name \
&& $4 == want_rev { found = 1 }
/^[[:space:]]*}/ { in_ov = 0 }
END { exit !found }
' "$json"
}
# _gspw_browser_name_known <playwright_core_path> <dir_name> — is the NAME
# listed at all, regardless of revision? (distinguishes "unknown revision"
# from "unreferenced" in the report.)
_gspw_browser_name_known() {
local json="$1/browsers.json" name rev
[ -r "$json" ] || return 1
read -r name rev <<< "$(_gspw_dir_name_parts "$2")"
awk -F'"' -v want="$name" '$2 == "name" && $4 == want { found = 1 }
END { exit !found }' "$json"
}
# _gspw_install_label <playwright_core_path> — "<dir-before-node_modules>
# <version>", e.g. "gstack 1.61.1".
_gspw_install_label() {
local pw_path="$1" parent version
parent=$(basename "$(dirname "$(dirname "$pw_path")")")
version=$(awk -F'"' '$2 == "version" { print $4; exit }' \
"$pw_path/package.json" 2>/dev/null) || true
printf '%s %s' "$parent" "${version:-?}"
}
# _gspw_registered_installs <cache_dir> — valid playwright-core paths (dir
# exists, browsers.json readable), one per line. A `.links` entry whose
# target is gone or unreadable is silently excluded here (it is counted as
# a broken link by the caller instead).
_gspw_registered_installs() {
local links_dir="$1/.links" f target
[ -d "$links_dir" ] || return 0
for f in "$links_dir"/*; do
[ -f "$f" ] || continue
target=$(cat "$f" 2>/dev/null) || true
[ -n "$target" ] || continue
if [ -d "$target" ] && [ -r "$target/browsers.json" ]; then
printf '%s\n' "$target"
fi
done
return 0
}
# _gspw_report_dir_line <dir_name> <install_paths_newline_sep> — prints the
# report line for one cache directory. Returns 1 only when truly
# unreferenced (caller tallies that); "unknown revision" does not count.
_gspw_report_dir_line() {
local dir_name="$1" installs="$2" p labels="" known=0
while IFS= read -r p; do
[ -n "$p" ] || continue
if _gspw_browser_referenced "$p" "$dir_name"; then
labels="${labels:+$labels, }$(_gspw_install_label "$p")"
elif _gspw_browser_name_known "$p" "$dir_name"; then
known=1
fi
done <<< "$installs"
if [ -n "$labels" ]; then
_gspw_info "$dir_name: $labels"
return 0
elif [ "$known" -eq 1 ]; then
_gspw_info "$dir_name: unknown revision"
return 0
fi
_gspw_info "$dir_name: unreferenced"
return 1
}
# gstack_browsers_report [cache_dir] — read-only. `$1` (or
# PLAYWRIGHT_BROWSERS_PATH, or ~/.cache/ms-playwright) is resolved once;
# "0" (documented as "bundle into node_modules") and any non-directory
# degrade to a silent no-cache path. `return 0` on every path.
gstack_browsers_report() {
local cache installs total links_total valid_count broken=0 unref=0 d name
cache="${1:-${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}}"
[ "$cache" = "0" ] && return 0
[ -d "$cache" ] || return 0
installs="$(_gspw_registered_installs "$cache")"
links_total=$(find "$cache/.links" -maxdepth 1 -type f 2>/dev/null \
| wc -l | tr -d ' ') || true
valid_count=$(printf '%s\n' "$installs" | grep -c . || true)
broken=$((links_total - valid_count))
total=$(du -sh "$cache" 2>/dev/null | awk '{print $1}') || true
_gspw_info "Playwright browsers: $cache (${total:-0})"
for d in "$cache"/*-[0-9]*; do
[ -d "$d" ] || continue
name=$(basename "$d")
_gspw_report_dir_line "$name" "$installs" || unref=$((unref + 1))
done
_gspw_info "${unref} unreferenced, ${broken} broken link(s)"
if [ "$unref" -gt 0 ] || [ "$broken" -gt 0 ]; then
_gspw_warn "unreferenced/broken Playwright browser dirs — re-run \
\`playwright install\`, which prunes stale revisions"
fi
return 0
}
# ── CLI dispatch (only when executed, not sourced) — browsers-report ONLY.
# The write functions (the bump, the submodule update) stay sourced-only: a
# CLI verb would expose `bun add playwright@latest` as a command-line entry
# point. ────────────────────────────────────────────────────────────────
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
case "${1:-}" in
browsers-report) shift; gstack_browsers_report "$@" ;;
*) echo "usage: gstack-playwright.sh browsers-report [cache_dir]" >&2
exit 2 ;;
esac
fi