v1.91.71.0 feat(install): gstack-doctor --check one-line verdict, gstack-capy-install, lazy Chromium (multi-agent wave PR A) (#3116)

* v1.91.71.0 feat(install): gstack-doctor --check one-line verdict, gstack-capy-install, lazy Chromium

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(qa-callers): substitute <plan-path> in the plan-completion acceptance block so the ship caller fixture admits the documented command

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:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-11 00:00:20 +00:00
1 parent 45a678323b
commit 6681adf7a8
49 files changed
+2700 -98

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# gstack-browser-ensure — install gstack's own Chromium on first use.
#
# Usage:
# gstack-browser-ensure [--check] [--quiet] [--with-deps]
#
# The Capy installer (bin/gstack-capy-install) and `./setup --no-browser` skip
# the Chromium download so planning-only machines never pay for it; the browse
# preflight in every browser skill (scripts/resolvers/browse.ts) runs this
# helper instead, so the first browser skill run installs it. Probes a
# Chromium launch through Playwright (node when present; Bun cannot launch
# Chromium on Windows) and, when the probe fails, runs `bunx playwright
# install chromium` under the same lock ./setup uses, then re-probes.
#
# --check probe only; never install (exit 1 when Chromium is unavailable)
# --quiet print only the final line
# --with-deps pass --with-deps to the Playwright installer (needs sudo)
#
# Last line on stdout: `BROWSER_OK <engine> <chromium version>` (exit 0) or
# `BROWSER_UNAVAILABLE <reason>` with a fix line (exit 1; code
# BROWSER_UNAVAILABLE, docs/troubleshooting.md#browser-ensure-failed).
# Honors GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT (seconds, default 600) and
# GSTACK_CHROMIUM_NO_SANDBOX=1 for the probe. Bash 3.2 plus POSIX tools.
BASH_COMPAT=50
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" || { echo "gstack-browser-ensure: cannot resolve the install root" >&2; exit 1; }
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#browser-ensure-failed"
CHECK=0 QUIET=0 WITH_DEPS=""
for _arg in "$@"; do
case "$_arg" in
--check) CHECK=1 ;;
--quiet) QUIET=1 ;;
--with-deps) WITH_DEPS="--with-deps" ;;
-h|--help) sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-browser-ensure: unknown argument '$_arg'" >&2; exit 2 ;;
esac
done
log() { [ "$QUIET" -eq 1 ] || echo "$@" >&2; }
fail() {
echo "BROWSER_UNAVAILABLE $1"
echo "fix: $2 (BROWSER_UNAVAILABLE; $DOCS)" >&2
exit 1
}
TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}"
case "$TIMEOUT" in ''|*[!0-9]*) TIMEOUT=600 ;; esac
[ "$TIMEOUT" -gt 0 ] 2>/dev/null || TIMEOUT=600
PROBE='(async () => { const { chromium } = require(process.cwd() + "/node_modules/playwright"); const args = process.env.GSTACK_CHROMIUM_NO_SANDBOX === "1" ? ["--no-sandbox"] : []; const b = await chromium.launch({ timeout: 30000, args }); try { const p = await b.newPage(); await p.setContent("<title>gstack smoke test</title><h1>Ready</h1>", { timeout: 10000 }); if (await p.title() !== "gstack smoke test") throw new Error("smoke render returned the wrong title"); console.log("RENDER_OK " + b.version()); } finally { await b.close(); } })().then(() => process.exit(0), e => { console.error(String(e && e.message || e).split("\n")[0]); process.exit(1); });'
# probe — sets PROBE_OUT and PROBE_RC; ENGINE names the runtime used.
probe() {
if command -v node >/dev/null 2>&1; then
ENGINE=node
if command -v timeout >/dev/null 2>&1; then PROBE_OUT=$(cd "$ROOT" && timeout 90 node -e "$PROBE" </dev/null 2>&1); PROBE_RC=$?
else PROBE_OUT=$(cd "$ROOT" && node -e "$PROBE" </dev/null 2>&1); PROBE_RC=$?; fi
elif command -v bun >/dev/null 2>&1; then
ENGINE=bun
PROBE_OUT=$(cd "$ROOT" && bun -e "$PROBE" </dev/null 2>&1); PROBE_RC=$?
else
fail "neither node nor bun is on PATH to launch Chromium" "install Node.js (https://nodejs.org) or Bun, then re-run $0"
fi
}
first_line() { printf '%s\n' "$1" | grep -v '^[[:space:]]*$' | head -1 | tr -cd ' -~' | cut -c1-200; }
ok() {
echo "BROWSER_OK $ENGINE $(printf '%s\n' "$PROBE_OUT" | sed -n 's/^RENDER_OK //p' | head -1 | tr -cd '0-9.' | cut -c1-20)"
exit 0
}
if [ ! -d "$ROOT/node_modules/playwright" ]; then
[ "$CHECK" -eq 1 ] && fail "playwright is not installed under $ROOT/node_modules" "cd $ROOT && ./setup"
command -v bun >/dev/null 2>&1 || fail "playwright is not installed under $ROOT/node_modules and bun is not on PATH" "install Bun, then cd $ROOT && ./setup"
log "gstack-browser-ensure: installing node modules (bun install)..."
(cd "$ROOT" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install, then re-run $0"
fi
probe
[ "$PROBE_RC" -eq 0 ] && ok
[ "$CHECK" -eq 1 ] && fail "Chromium did not launch via $ENGINE ($(first_line "$PROBE_OUT"))" "$ROOT/bin/gstack-browser-ensure (installs Chromium; ~115 MiB)"
command -v bun >/dev/null 2>&1 || fail "Chromium is not installed and bun is not on PATH to install it" "install Bun, then re-run $0"
# Same lock ./setup takes (#2136), so two installs never race on the cache.
LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock"
tries=0
while ! mkdir "$LOCK" 2>/dev/null; do
holder=$(cat "$LOCK/pid" 2>/dev/null || true)
case "$holder" in ''|*[!0-9]*) holder=0 ;; esac
if [ "$holder" -eq 0 ] || ! kill -0 "$holder" 2>/dev/null; then rm -rf "$LOCK" 2>/dev/null; continue; fi
tries=$((tries + 1))
[ "$tries" -ge "$TIMEOUT" ] && fail "another gstack install holds the Chromium lock ($LOCK, pid $holder)" "wait for it, or remove a stale lock: rm -rf \"$LOCK\""
sleep 1
done
echo "$$" > "$LOCK/pid" 2>/dev/null || true
trap 'rm -rf "$LOCK" 2>/dev/null' EXIT INT TERM
OVERRIDE=""
if [ -f /etc/os-release ] && grep -q '^ID=ubuntu' /etc/os-release && grep -q '^VERSION_ID="26.04"' /etc/os-release; then
case "$(uname -m)" in x86_64) OVERRIDE="ubuntu24.04-x64" ;; aarch64|arm64) OVERRIDE="ubuntu24.04-arm64" ;; esac
fi
log "gstack-browser-ensure: installing Playwright Chromium (one-time, ~115 MiB; bound ${TIMEOUT}s)..."
started=$(date +%s)
(
cd "$ROOT"
if [ -n "$OVERRIDE" ]; then PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$OVERRIDE" bunx playwright install $WITH_DEPS chromium
else bunx playwright install $WITH_DEPS chromium; fi
) </dev/null >&2 &
pid=$!
waited=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$TIMEOUT" ]; then kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; fail "bunx playwright install chromium exceeded ${TIMEOUT}s" "raise GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds> and re-run $0"; fi
sleep 1; waited=$((waited + 1))
done
wait "$pid"; rc=$?
[ "$rc" -eq 0 ] || fail "bunx playwright install chromium exited $rc (offline, proxy, or blocked download?)" "fix network access to the Playwright CDN, then re-run $0"
log "gstack-browser-ensure: Chromium installed in $(( $(date +%s) - started ))s"
probe
[ "$PROBE_RC" -eq 0 ] && ok
fail "Chromium installed but did not launch via $ENGINE ($(first_line "$PROBE_OUT"))" "on Ubuntu 24.04+ try GSTACK_CHROMIUM_NO_SANDBOX=1 $0 (#2157)"
+15
View File
@@ -16,6 +16,21 @@
GSTACK_BUN_FLOOR="1.3.3"
GSTACK_BUN_TESTED="1.4.2"
# gstack_bun_archive_sha256 TARGET — the SHA-256 of
# https://github.com/oven-sh/bun/releases/download/bun-v$GSTACK_BUN_TESTED/bun-TARGET.zip
# from that release's SHASUMS256.txt, for the targets bin/gstack-capy-install
# installs on Capy cloud machines (the same exact-archive recipe as
# .github/docker/Dockerfile.ci; test/bun-version-drift.test.ts keeps them
# equal). Prints nothing and returns 1 for a target without a verified archive.
gstack_bun_archive_sha256() {
case "$1" in
linux-x64) echo 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;;
linux-x64-baseline) echo c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f ;;
linux-aarch64) echo 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;;
*) return 1 ;;
esac
}
# _gstack_bun_parse <version> — sets _gb_core ("MAJOR MINOR PATCH" as decimal
# integers) and _gb_pre (prerelease, may be empty); returns 1 when malformed.
# A leading "v" and "+build" metadata are ignored.
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# gstack-capy-install — install gstack on a Capy cloud machine (Ubuntu) and
# certify it with one grep-able line.
#
# Usage:
# gstack-capy-install [--for <skill> | --only <skill> | --require <id,id>] [--with-browser]
# [--revision <ref>] [--project <path>] [--root <dir>]
#
# Steps: clone or reuse the checkout under a lock (never reset, never pulled);
# preflight git, curl, node, jq and Bun (Bun is installed or upgraded from the
# exact release archive, SHA-256 verified, only when gstack's supported minimum
# and the project's pin agree); ./setup --host claude --no-prefix; ./setup
# --host codex --model gpt-6-astra; gstack-doctor --check. Chromium is lazy by
# default (bin/gstack-browser-ensure installs it on first use); --with-browser
# installs it now. The last lines are the doctor's trailer (`gstack: ok ...` or
# `gstack: fail ...`; grep that, never pipe through tail) and the GSTACK_LAUNCH
# line the parent supplies to every gstack command it launches.
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
set -u
BASH_COMPAT=50
SELF="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT=""
usage() { sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; }
need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; }
while [ $# -gt 0 ]; do
case "$1" in
--for|--only) need "$1" "${2:-}"; FOR="$2"; shift ;;
--require) need "$1" "${2:-}"; REQUIRE="$2"; shift ;;
--with-browser) WITH_BROWSER=1 ;;
--revision) need "$1" "${2:-}"; REVISION="$2"; shift ;;
--project) need "$1" "${2:-}"; PROJECT="$2"; shift ;;
--root) need "$1" "${2:-}"; ROOT="$2"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "gstack-capy-install: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
shift
done
[ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; }
[ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch"
say() { echo "gstack-capy-install: $*"; }
stop() { # CODE MESSAGE FIX — print and exit 3 before ./setup
echo "gstack-capy-install: $2; fix: $3 ($1; $DOCS#$(gstack_result_anchor "$1" 2>/dev/null || echo capy-install-preflight-failed))" >&2
exit 3
}
gstack_result_anchor() { case "$1" in INSTALL_PREFLIGHT_FAILED) echo capy-install-preflight-failed ;; RUNTIME_PIN_CONFLICT) echo capy-install-runtime-conflict ;; *) return 1 ;; esac; }
now() { date +%s; }
T0=$(now); ORIG_PWD=$(pwd)
# 1. Clone or reuse, under the same lock the user-scope skill takes.
mkdir -p "$(dirname "$ROOT")" || stop INSTALL_PREFLIGHT_FAILED "cannot create $(dirname "$ROOT")" "choose a writable --root"
command -v git >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: git not found" "apt-get install -y git"
exec 9>"$(dirname "$ROOT")/.gstack-capy-install.lock"
if command -v flock >/dev/null 2>&1; then flock -w 900 9 || stop INSTALL_PREFLIGHT_FAILED "another gstack-capy-install holds $(dirname "$ROOT")/.gstack-capy-install.lock" "wait for it to finish"; fi
if [ ! -f "$ROOT/setup" ] || [ ! -f "$ROOT/VERSION" ]; then
[ -e "$ROOT" ] && [ -n "$(ls -A "$ROOT" 2>/dev/null)" ] && stop INSTALL_PREFLIGHT_FAILED "$ROOT exists but is not a gstack checkout" "remove it or pass --root <dir>"
say "cloning $REPO into $ROOT${REVISION:+ at $REVISION}"
if [ -n "$REVISION" ] && git clone --quiet --single-branch --depth 1 --branch "$REVISION" "$REPO" "$ROOT" 2>/dev/null; then :
elif [ -n "$REVISION" ]; then
git clone --quiet "$REPO" "$ROOT" && git -C "$ROOT" checkout --quiet "$REVISION" || stop INSTALL_PREFLIGHT_FAILED "could not clone $REPO at $REVISION" "check the revision and network, then re-run"
else
git clone --quiet --single-branch --depth 1 "$REPO" "$ROOT" || stop INSTALL_PREFLIGHT_FAILED "could not clone $REPO" "check network access to github.com, then re-run"
fi
else
say "reusing $ROOT ($(head -1 "$ROOT/VERSION")); never reset or pulled"
fi
. "$ROOT/bin/gstack-bun-version.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-bun-version.sh is missing" "re-clone gstack"
# 2. Preflight: tools, then Bun against gstack's minimum and the project's pin.
for tool in curl node jq; do
command -v "$tool" >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: $tool not found" "install $tool (apt-get install -y jq; Node from https://nodejs.org or nvm), then re-run"
done
[ -n "$PROJECT" ] || { PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT" ] && PROJECT=""; }
PIN="" PIN_SRC=""
if [ -n "$PROJECT" ] && [ -f "$PROJECT/package.json" ]; then
PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1)
[ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json"
fi
if [ -z "$PIN" ] && [ -n "$PROJECT" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi
WANT="$GSTACK_BUN_TESTED"
case "$PIN" in
'') ;;
'>='*) _gstack_bun_below "$WANT" "${PIN#>=}" && stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins $PIN" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" ;;
[0-9]*.[0-9]*.[0-9]*) [ "$PIN" = "$WANT" ] || stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins exactly $PIN" "raise the project's pin to $WANT, or install gstack on a machine the project accepts" ;;
*) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;;
esac
HAVE=$(bun --version 2>/dev/null | head -1 || true)
case "$(gstack_bun_status "${HAVE:-0.0.0}")" in
ok) say "bun $HAVE satisfies gstack's supported minimum $WANT${PIN:+ and the project pin $PIN}" ;;
*)
say "bun ${HAVE:-absent} is below gstack's supported minimum $WANT${PIN:+ (project pin $PIN agrees)}; installing bun $WANT from its release archive (SHA-256 verified)"
case "$(uname -ms)" in
'Linux x86_64') TARGET=linux-x64; grep -q avx2 /proc/cpuinfo 2>/dev/null || TARGET=linux-x64-baseline ;;
'Linux aarch64'|'Linux arm64') TARGET=linux-aarch64 ;;
*) stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $(uname -ms) (this installer is for Capy cloud Ubuntu)" "install Bun $WANT by hand (https://bun.sh), then re-run" ;;
esac
SHA=$(gstack_bun_archive_sha256 "$TARGET") || stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $TARGET" "install Bun $WANT by hand, then re-run"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-bun.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir" "point TMPDIR at a writable directory"
curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run"
echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; }
BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
mkdir -p "$BUN_INSTALL/bin" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$BUN_INSTALL/bin/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $BUN_INSTALL/bin" "install unzip, or set BUN_INSTALL to a writable prefix"; }
rm -rf "$TMP"
export PATH="$BUN_INSTALL/bin:$PATH"
HAVE=$(bun --version 2>/dev/null | head -1)
[ "$HAVE" = "$WANT" ] || stop INSTALL_PREFLIGHT_FAILED "installed bun reports $HAVE, expected $WANT" "check PATH ($BUN_INSTALL/bin must come first), then re-run"
say "bun $WANT installed at $BUN_INSTALL/bin/bun (prepend $BUN_INSTALL/bin to PATH in later shells)" ;;
esac
# 3. Install both host profiles; Chromium lazy unless --with-browser.
cd "$ROOT" || exit 1
export GSTACK_SKIP_COREUTILS="${GSTACK_SKIP_COREUTILS:-1}" GSTACK_SKIP_ASIDE="${GSTACK_SKIP_ASIDE:-1}"
BROWSER_FLAG="--no-browser"; [ "$WITH_BROWSER" -eq 1 ] && BROWSER_FLAG=""
T1=$(now)
./setup --host claude --no-prefix $BROWSER_FLAG </dev/null || { echo "gstack-capy-install: ./setup --host claude failed (exit $?)" >&2; exit 1; }
./setup --host codex --model gpt-6-astra $BROWSER_FLAG </dev/null || { echo "gstack-capy-install: ./setup --host codex failed (exit $?)" >&2; exit 1; }
T2=$(now)
say "planning-only install: $((T2 - T1))s (setup both hosts; Chromium not downloaded)"
if [ "$WITH_BROWSER" -eq 1 ]; then
T3=$(now)
"$ROOT/bin/gstack-browser-ensure" </dev/null | tail -1
say "browser bootstrap: $(( $(now) - T3 ))s"
else
echo "SKIP browser ($(. "$ROOT/bin/gstack-doctor-components.sh" && printf '%s' "$GSTACK_BROWSER_LAZY_REASON"))"
fi
# 4. Certify. The trailer is the contract; `(requested <ref>; ...)` when the
# existing checkout is not at --revision (it is never moved here).
STATE_ROOT=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:=$STATE_ROOT}"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
INSTALLED=$(head -1 "$ROOT/VERSION")
REV_NOTE=""
if [ -n "$REVISION" ] && [ "$REVISION" != "$INSTALLED" ] && [ "$REVISION" != "v$INSTALLED" ] \
&& [ "$(git -C "$ROOT" rev-parse --verify --quiet "$REVISION^{commit}" 2>/dev/null)" != "$(git -C "$ROOT" rev-parse HEAD 2>/dev/null)" ]; then
REV_NOTE=" (requested $REVISION; run gstack-upgrade to move)"
fi
if [ -n "$FOR" ]; then set -- --for "$FOR"; else set -- --require "$REQUIRE"; fi
[ "$WITH_BROWSER" -eq 1 ] && [ -z "$FOR" ] && set -- --require "$REQUIRE,browse-bundle,browser"
OUT=$(cd "$ORIG_PWD" && GSTACK_EPHEMERAL="${GSTACK_EPHEMERAL:-1}" "$ROOT/bin/gstack-doctor" --check "$@" ${PROJECT:+--project "$PROJECT"} 2>&1); RC=$?
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1}"
printf '%s\n' "$OUT" | grep '^gstack: ' | sed "s/\$/$(printf '%s' "$REV_NOTE" | sed 's/[&/\]/\\&/g')/"
exit "$RC"
+41 -8
View File
@@ -4,6 +4,14 @@
# Usage:
# gstack-doctor print one row per check; spends nothing
# gstack-doctor --live also run the paid Codex model probe (one short call)
# gstack-doctor --check [--for <skill> | --require <id,id>] [--project <path>]
# [--revision <ref>] [--json] [--live]
# one PASS | FAIL | SKIP row per component, then one
# trailer: `gstack: ok <revision> for=<what>` (exit 0)
# or `gstack: fail <id,id>` (exit 1). --for names the
# workflow to certify; --require names components; the
# default is every host installed from this checkout.
# Rows and ids: bin/gstack-doctor-components.sh.
#
# Rows: install root, state root, bun, hooks, codex, codex probe, artifacts
# sync, browse bundle, binaries, cso, claude code, session journal, guard log. Each row is
@@ -28,16 +36,28 @@ BASH_COMPAT=50
set -u
usage() {
sed -n '3,6p' "$0" | sed 's/^# \{0,1\}//'
sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//'
}
LIVE=0
for _arg in "$@"; do
case "$_arg" in
LIVE=0 CHECK=0 CHECK_JSON=0 CHECK_FOR="" CHECK_REQUIRE="" CHECK_PROJECT="" CHECK_REVISION=""
_need_value() { [ -n "${2:-}" ] || { echo "gstack-doctor: $1 needs a value" >&2; usage >&2; exit 2; }; }
while [ $# -gt 0 ]; do
case "$1" in
--live) LIVE=1 ;;
--check) CHECK=1 ;;
--json) CHECK=1; CHECK_JSON=1 ;;
--for) _need_value "$1" "${2:-}"; CHECK=1; CHECK_FOR="$2"; shift ;;
--for=*) CHECK=1; CHECK_FOR="${1#--for=}" ;;
--require) _need_value "$1" "${2:-}"; CHECK=1; CHECK_REQUIRE="$2"; shift ;;
--require=*) CHECK=1; CHECK_REQUIRE="${1#--require=}" ;;
--project) _need_value "$1" "${2:-}"; CHECK=1; CHECK_PROJECT="$2"; shift ;;
--project=*) CHECK=1; CHECK_PROJECT="${1#--project=}" ;;
--revision) _need_value "$1" "${2:-}"; CHECK=1; CHECK_REVISION="$2"; shift ;;
--revision=*) CHECK=1; CHECK_REVISION="${1#--revision=}" ;;
-h|--help) usage; exit 0 ;;
*) echo "gstack-doctor: unknown argument '$_arg'" >&2; usage >&2; exit 2 ;;
*) echo "gstack-doctor: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
shift
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)" || { echo "gstack-doctor: cannot resolve the install root" >&2; exit 1; }
@@ -45,6 +65,7 @@ ROOT_REAL="$(cd "$ROOT" && pwd -P)"
BIN="$ROOT/bin"
SRC="$ROOT_REAL"
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
DOCS_STATE_ROOT="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
CODEX_FEATURES="outside reviews in /review, /ship, /autoplan and /codex"
N_OK=0 N_WARN=0 N_NC=0 N_FAIL=0
@@ -82,11 +103,17 @@ json_field() {
printf '%s' "$v"
}
# check_state_root — durability comes from the GSTACK_EPHEMERAL=1 marker the
# host sets (docs/state-root.md), never from a path heuristic.
check_state_root() {
if [ -d "$STATE_ROOT" ]; then
row ok "state root" "$STATE_ROOT (selected by $_gstack_sr_var)"
local durable=yes
[ "${GSTACK_EPHEMERAL:-}" = 1 ] && durable=no
if [ ! -d "$STATE_ROOT" ]; then
row warn "state root" "$STATE_ROOT durable=$durable (selected by $_gstack_sr_var) does not exist yet" "cd $SRC && ./setup, or run any gstack skill"
elif [ "$durable" = no ]; then
row warn "state root" "$STATE_ROOT durable=no (selected by $_gstack_sr_var; GSTACK_EPHEMERAL=1: learnings and logs vanish with this machine)" "set GSTACK_STATE_ROOT to a directory that outlives this machine ($DOCS_STATE_ROOT#ephemeral-machines)"
else
row warn "state root" "$STATE_ROOT (selected by $_gstack_sr_var) does not exist yet" "cd $SRC && ./setup, or run any gstack skill"
row ok "state root" "$STATE_ROOT durable=yes (selected by $_gstack_sr_var)"
fi
}
@@ -438,6 +465,12 @@ GSTACK_STATE_ROOT="$STATE_ROOT"
EXE=""
case "${OSTYPE:-}" in msys*|cygwin*|win32*) EXE=".exe" ;; esac
if [ "$CHECK" -eq 1 ]; then
. "$BIN/gstack-doctor-check.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-check.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
gstack_doctor_check
exit $?
fi
echo "gstack doctor $(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)"
check_install
check_state_root
+398
View File
@@ -0,0 +1,398 @@
# shellcheck shell=bash
# gstack-doctor-check.sh — `gstack-doctor --check`: the one-line install verdict.
#
# Sourced by bin/gstack-doctor after its prelude (ROOT, ROOT_REAL, BIN, SRC,
# DOCS, STATE_ROOT, EXE, clamp, the launch probe, the codex status helpers)
# and never executed on its own. Prints `required: <ids>`, one
# `PASS | FAIL | SKIP <id>` row per component in bin/gstack-doctor-components.sh
# (generated from lib/doctor-components.ts; the same ids feed --for, --require
# and --json), then exactly one trailer: `gstack: ok <revision> for=<what>` on
# exit 0, or `gstack: fail <id,id>` on exit 1. Every FAIL row carries a `fix:`
# clause and a lib/result-codes.ts code with its docs/troubleshooting.md anchor.
# Nothing here writes. Bash 3.2 plus POSIX tools; it reports with bun absent
# (the `pins` row then SKIPs with a reason instead of vanishing). The paid
# Codex model probe runs only with --live.
#
# Inputs (set by bin/gstack-doctor): CHECK_FOR (skill), CHECK_REQUIRE (csv),
# CHECK_JSON (0|1), CHECK_PROJECT (path or ""), CHECK_REVISION (ref or ""), LIVE.
. "$BIN/gstack-doctor-components.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-components.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
. "$BIN/gstack-install-registry.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-install-registry.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
. "$BIN/gstack-bun-version.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-bun-version.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
_dc_rows="" # one "status<TAB>id<TAB>detail<TAB>fix<TAB>code" per line
_dc_failed="" # comma-separated FAIL ids
_dc_required="" # space-separated required ids (core included)
_dc_optional="" # space-separated ids that SKIP when absent
_dc_label="" # for=<...> in the trailer
_dc_host_rows="" # cached _gstack_status_rows
# _dc_has LIST ID — true when the space-separated LIST contains ID.
_dc_has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
# _dc_url CODE — the (CODE; docs anchor) suffix for a FAIL row.
_dc_url() { printf '(%s; %s#%s)' "$1" "$DOCS" "$(gstack_result_anchor "$1")"; }
# _dc_row STATUS ID DETAIL [FIX] [CODE] — record and (in text mode) print one row.
_dc_row() {
local status="$1" id="$2" detail="$3" fix="${4:-}" code="${5:-}"
_dc_rows="$_dc_rows$status $id $detail $fix $code
"
[ "$status" = FAIL ] && _dc_failed="${_dc_failed:+$_dc_failed,}$id"
[ "$CHECK_JSON" -eq 1 ] && return 0
case "$status" in
PASS) printf 'PASS %s%s\n' "$id" "${detail:+ $detail}" ;;
SKIP) printf 'SKIP %s (%s)\n' "$id" "$detail" ;;
FAIL) printf 'FAIL %s — %s; fix: %s %s\n' "$id" "$detail" "$fix" "$(_dc_url "$code")" ;;
esac
}
# _dc_fail_or_skip ID DETAIL FIX CODE SKIP_REASON — FAIL when required, else SKIP.
_dc_fail_or_skip() {
if _dc_has "$_dc_required" "$1"; then _dc_row FAIL "$1" "$2" "$3" "$4"; else _dc_row SKIP "$1" "$5"; fi
}
# _dc_json_str TEXT — TEXT as a JSON string literal.
_dc_json_str() {
printf '"%s"' "$(printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr -d '\000-\010\013-\037' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }')"
}
# _dc_resolve_required — fills _dc_required, _dc_optional and _dc_label from
# --for / --require / the installed hosts. Exits 2 on an unknown skill or id.
_dc_resolve_required() {
local extra="" id host csv
if [ -n "$CHECK_FOR" ] && [ -n "$CHECK_REQUIRE" ]; then
echo "gstack-doctor: --for and --require are exclusive" >&2; exit 2
fi
if [ -n "$CHECK_FOR" ]; then
extra=$(gstack_doctor_skill_requires "$CHECK_FOR") || { echo "gstack-doctor: unknown skill '$CHECK_FOR' (known: $GSTACK_DOCTOR_SKILLS)" >&2; exit 2; }
_dc_optional=$(gstack_doctor_skill_optional "$CHECK_FOR")
_dc_label="for=$CHECK_FOR"
elif [ -n "$CHECK_REQUIRE" ]; then
csv=$(printf '%s' "$CHECK_REQUIRE" | tr ',' ' ')
for id in $csv; do
gstack_doctor_component_kind "$id" >/dev/null || { echo "gstack-doctor: unknown component '$id' in --require (known: $GSTACK_DOCTOR_COMPONENTS)" >&2; exit 2; }
extra="${extra:+$extra }$id"
done
_dc_label="require=$(printf '%s' "$extra" | tr ' ' ',')"
else
for host in $GSTACK_DOCTOR_HOSTS; do
_dc_host_status "$host"
[ "$_dc_hs_result" = none ] || extra="${extra:+$extra }$host"
done
if [ -n "$extra" ]; then
_dc_label="for=installed-hosts:$(printf '%s' "$extra" | tr ' ' ',')"
else
extra="claude"
_dc_label="for=installed-hosts:none(claude required)"
fi
fi
_dc_required="${extra:+$extra }$GSTACK_DOCTOR_CORE"
}
# _dc_host_status HOST — the registry's verdict for HOST installed from this
# checkout: sets _dc_hs_result (current|stale|missing|unregistered|none),
# _dc_hs_dest, _dc_hs_scope, _dc_hs_ver, _dc_hs_note.
_dc_host_status() {
local host scope src dest reg cur result note real
_dc_hs_result=none _dc_hs_dest="" _dc_hs_scope="" _dc_hs_ver="" _dc_hs_note=""
[ -n "$_dc_host_rows" ] || _dc_host_rows="$(_gstack_status_rows)"
while IFS=' ' read -r host scope src dest reg cur result note; do
[ "$host" = "$1" ] || continue
real="$(cd "$src" 2>/dev/null && pwd -P)"
if [ "$real" != "$ROOT_REAL" ] && [ "$(cd "$dest/gstack" 2>/dev/null && pwd -P)" != "$ROOT_REAL" ]; then continue; fi
[ "$_dc_hs_result" = current ] && continue
_dc_hs_result="$result" _dc_hs_dest="$dest" _dc_hs_scope="$scope" _dc_hs_ver="$cur" _dc_hs_note="$note"
done <<ROWS
$_dc_host_rows
ROWS
}
_dc_check_host() {
local id="$1" fix="cd $SRC && ./setup --host $1"
[ "$1" = claude ] && fix="$fix --no-prefix"
[ "$1" = codex ] && fix="$fix --model <model id, e.g. gpt-6-astra>"
_dc_host_status "$id"
case "$_dc_hs_result" in
current) _dc_row PASS "$id" "$_dc_hs_scope current ($_dc_hs_ver) at $_dc_hs_dest" ;;
none) _dc_fail_or_skip "$id" "$(gstack_doctor_component_title "$id") not installed from $ROOT" "$fix" COMPONENT_MISSING "not needed; $(gstack_doctor_component_title "$id") not installed" ;;
*) _dc_fail_or_skip "$id" "$(gstack_doctor_component_title "$id") $_dc_hs_result at $_dc_hs_dest${_dc_hs_note:+ ($_dc_hs_note)}" "$fix" COMPONENT_MISSING "not needed; $(gstack_doctor_component_title "$id") $_dc_hs_result" ;;
esac
}
_dc_check_patch() {
local file="" model fix="cd $SRC && ./setup --host codex --model <model id, e.g. gpt-6-astra>"
_dc_host_status codex
if [ "$_dc_hs_result" = none ]; then
_dc_fail_or_skip patch "Codex skills are not installed, so the Codex render carries no behavioral patch" "$fix" COMPONENT_MISSING "not needed; Codex skills not installed"
return
fi
for file in "$_dc_hs_dest/gstack-review/SKILL.md" "$_dc_hs_dest/review/SKILL.md"; do [ -f "$file" ] && break; done
if [ ! -f "$file" ]; then
_dc_fail_or_skip patch "no review skill in the Codex render at $_dc_hs_dest" "$fix" COMPONENT_MISSING "not needed; no Codex review render"
return
fi
model=$(grep -F 'Model-Specific Behavioral Patch (' "$file" 2>/dev/null | head -1 | sed 's/.*Model-Specific Behavioral Patch (\([^)]*\)).*/\1/' | clamp 'A-Za-z0-9._:/-' 60)
if [ -n "$model" ]; then
_dc_row PASS patch "$model (${file#"$_dc_hs_dest"/})"
else
_dc_fail_or_skip patch "no 'Model-Specific Behavioral Patch' heading in $file (rendered without --model)" "$fix" COMPONENT_MISSING "not needed; no model patch in the Codex render"
fi
}
_dc_check_codex_cli() {
local probe="$BIN/gstack-codex-probe" reviews sel rc model out state optional_note="optional; a Claude subagent serves as the outside voice"
reviews=$("$BIN/gstack-config" get codex_reviews 2>/dev/null || echo enabled)
if ! command -v codex >/dev/null 2>&1; then
_dc_fail_or_skip codex-cli "Codex CLI not installed" "npm install -g @openai/codex, then codex login" COMPONENT_MISSING "$optional_note; Codex CLI not installed"
return
fi
if [ "$reviews" = disabled ]; then
_dc_fail_or_skip codex-cli "codex_reviews disabled" "$BIN/gstack-config set codex_reviews enabled" COMPONENT_MISSING "$optional_note; codex_reviews disabled"
return
fi
sel=$("$probe" select-model exec </dev/null 2>&1); rc=$?
model=$(printf '%s\n' "$sel" | sed -n 's/^CODEX_SEL: //p' | clamp 'A-Za-z0-9._:/-' 80)
if [ "$rc" -ne 0 ] || [ -z "$model" ]; then
_dc_fail_or_skip codex-cli "select-model exited $rc without a model" "run $probe select-model exec for the cause; reinstall with cd $SRC && ./setup" COMPONENT_MISSING "$optional_note; the Codex helper could not select a model"
return
fi
if ! "$probe" check-auth </dev/null >/dev/null 2>&1; then
_dc_fail_or_skip codex-cli "installed but not authenticated (model $model)" "codex login, or: printenv OPENAI_API_KEY | codex login --with-api-key" COMPONENT_MISSING "$optional_note; Codex CLI not authenticated"
return
fi
gstack_codex_version
if [ "$LIVE" -eq 1 ]; then
out=$("$probe" probe-model exec </dev/null 2>&1); rc=$?
state=$(printf '%s\n' "$out" | sed -n 's/^CODEX_PROBE_STATE: //p' | clamp 'a-z_' 40)
case "$rc" in
0) _dc_row PASS codex-cli "${_gcx_version:-version unknown}; model $model; live probe ok${state:+ ($state)}" ;;
*) _dc_fail_or_skip codex-cli "${_gcx_version:-version unknown}; model $model; live probe exit $rc${state:+ ($state)}" "set GSTACK_CODEX_MODEL=<supported-model>, or read $probe probe-model exec" COMPONENT_MISSING "$optional_note; live probe exit $rc${state:+ ($state)}" ;;
esac
return
fi
_dc_row PASS codex-cli "${_gcx_version:-version unknown}; model $model; authenticated (paid probe not run; add --live)"
}
_dc_check_browse_bundle() {
local missing="" f fix="cd $SRC && ./setup"
if [ ! -x "$ROOT/browse/dist/browse$EXE" ]; then
_dc_fail_or_skip browse-bundle "browse/dist not built" "$fix" COMPONENT_MISSING "not needed; browse bundle not built"
return
fi
gstack_launch_probe "$ROOT/browse/dist/browse$EXE"
case "$_glp_state" in
native) ;;
*) _dc_fail_or_skip browse-bundle "browse/dist/browse $_glp_state at launch (${_glp_detail:-no output})" "$fix" COMPONENT_MISSING "not needed; browse binary $_glp_state"; return ;;
esac
for f in server-node.mjs .build-complete; do [ -e "$ROOT/browse/dist/$f" ] || missing="${missing:+$missing, }$f"; done
if [ -n "$missing" ]; then
_dc_fail_or_skip browse-bundle "incomplete build (missing browse/dist: $missing)" "$fix" COMPONENT_MISSING "not needed; incomplete browse build"
else
_dc_row PASS browse-bundle "browse/dist built and launches"
fi
}
# _dc_check_browser — Chromium launch plus a setContent smoke render, through
# node when present (Bun cannot launch Chromium on Windows) else bun. Only when
# required: a planning machine never pays for the launch.
_dc_check_browser() {
local out rc engine fix="$ROOT/bin/gstack-browser-ensure" script
if ! _dc_has "$_dc_required" browser; then
_dc_row SKIP browser "$GSTACK_BROWSER_LAZY_REASON"
return
fi
script='(async () => { const { chromium } = require(process.cwd() + "/node_modules/playwright"); const b = await chromium.launch({ timeout: 30000 }); try { const p = await b.newPage(); await p.setContent("<title>gstack smoke test</title><h1>Ready</h1>", { timeout: 10000 }); if (await p.title() !== "gstack smoke test") throw new Error("smoke render returned the wrong title"); console.log("RENDER_OK " + b.version()); } finally { await b.close(); } })().then(() => process.exit(0), e => { console.error(String(e && e.message || e).split("\\n")[0]); process.exit(1); });'
if command -v node >/dev/null 2>&1; then
engine=node
out=$(cd "$ROOT" && { command -v timeout >/dev/null 2>&1 && timeout 90 node -e "$script" </dev/null 2>&1 || node -e "$script" </dev/null 2>&1; }); rc=$?
elif command -v bun >/dev/null 2>&1; then
engine=bun
out=$(cd "$ROOT" && bun -e "$script" </dev/null 2>&1); rc=$?
else
_dc_row FAIL browser "neither node nor bun is on PATH to launch Chromium" "install Node.js (https://nodejs.org) or Bun, then $fix" BROWSER_UNAVAILABLE
return
fi
if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -q '^RENDER_OK'; then
_dc_row PASS browser "Chromium $(printf '%s\n' "$out" | sed -n 's/^RENDER_OK //p' | clamp '0-9.' 20) launched and rendered ($engine)"
else
_dc_row FAIL browser "Chromium did not launch and render via $engine (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | head -1 | clamp ' -~' 160))" "$fix" BROWSER_UNAVAILABLE
fi
}
_dc_check_cso() {
local file="$SRC/bin/.gstack-cso-build-result" reason=""
if [ -x "$SRC/bin/gstack-cso-launcher$EXE" ] && [ -x "$SRC/bin/gstack-cso-core$EXE" ]; then
_dc_row PASS cso "native helper built"
return
fi
[ -f "$file" ] && reason=$(sed -n 's/^reason=//p' "$file" 2>/dev/null | head -1 | clamp ' -~' 100)
_dc_fail_or_skip cso "native helper not built${reason:+ ($reason)}" "cd $SRC && ./setup (it names any missing build prerequisite)" COMPONENT_MISSING "optional; toolchain absent or helper not built${reason:+: $reason}"
}
_dc_check_runtime() {
local where found fix="install Bun $GSTACK_BUN_TESTED with the checksum-verified recipe in $SRC/bin/gstack-capy-install (or: bun upgrade), then cd $SRC && ./setup"
if ! where=$(command -v bun 2>/dev/null); then
_dc_row FAIL runtime "bun not found on PATH (gstack needs $GSTACK_BUN_TESTED; floor $GSTACK_BUN_FLOOR)" "$fix" RUNTIME_BELOW_MINIMUM
return
fi
found=$(bun --version </dev/null 2>/dev/null | clamp 'A-Za-z0-9.+-' 40)
_dc_bun="$found"
case "$(gstack_bun_status "$found")" in
ok) _dc_row PASS runtime "bun $found at $where (floor $GSTACK_BUN_FLOOR, supported minimum $GSTACK_BUN_TESTED)" ;;
untested) _dc_row FAIL runtime "bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED (above the security floor $GSTACK_BUN_FLOOR)" "$fix" RUNTIME_BELOW_MINIMUM ;;
too-old) _dc_row FAIL runtime "bun $found at $where is below the security floor $GSTACK_BUN_FLOOR (supported minimum $GSTACK_BUN_TESTED)" "$fix" RUNTIME_BELOW_MINIMUM ;;
*) _dc_row FAIL runtime "could not read the version of $where" "$fix" RUNTIME_BELOW_MINIMUM ;;
esac
}
_dc_check_pins() {
local project="$CHECK_PROJECT" node="" out rc verdict summary failline
if ! command -v bun >/dev/null 2>&1; then
_dc_row SKIP pins "no bun to evaluate the project pins${project:+ of $project} (install Bun, then re-run)"
return
fi
if [ -z "$project" ]; then
project=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$project" ]; then
_dc_row SKIP pins "no project: $(pwd) is not inside a git repository; pass --project <path>"
return
fi
if [ "$(cd "$project" && pwd -P)" = "$ROOT_REAL" ]; then
_dc_row SKIP pins "no project: $(pwd) is inside the gstack checkout itself; pass --project <path>"
return
fi
fi
if [ ! -d "$project" ]; then
_dc_row SKIP pins "project directory $project does not exist; pass --project <path>"
return
fi
node=$(node --version </dev/null 2>/dev/null | clamp 'v0-9.' 20); node="${node#v}"
out=$(bun "$BIN/gstack-runtime-pins.ts" --project "$project" ${_dc_bun:+--bun "$_dc_bun"} ${node:+--node "$node"} </dev/null 2>&1); rc=$?
verdict=$(printf '%s\n' "$out" | sed -n 's/^PINS_VERDICT: //p' | tail -1 | clamp 'a-z' 8)
summary=$(printf '%s\n' "$out" | sed -n 's/^PIN_SUMMARY: //p' | tr '\n' '|' | sed 's/|$//; s/|/; /g')
case "$verdict" in
pass) _dc_row PASS pins "$project: $summary" ;;
none) _dc_row PASS pins "$project pins no runtime (no engines, .tool-versions, .nvmrc, .bun-version or CI pin)" ;;
fail) failline=$(printf '%s\n' "$out" | sed -n 's/^PIN_FAIL: //p' | tr '\n' '|' | sed 's/|$//; s/|/; /g')
_dc_row FAIL pins "$failline" "install the pinned version for this platform (bin/gstack-capy-install upgrades Bun when gstack and the project agree), or change the pin in $project" PROJECT_PIN_MISMATCH ;;
*) _dc_row SKIP pins "gstack-runtime-pins could not evaluate $project (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | head -1 | clamp ' -~' 120))" ;;
esac
}
_dc_check_state_root() {
local durable=yes note="no GSTACK_EPHEMERAL marker"
if [ "${GSTACK_EPHEMERAL:-}" = 1 ]; then
durable=no; note="GSTACK_EPHEMERAL=1: learnings and logs vanish with this machine; set GSTACK_STATE_ROOT for a durable root"
fi
[ -d "$STATE_ROOT" ] || note="$note; not created yet"
if [ -d "$STATE_ROOT" ] && [ ! -w "$STATE_ROOT" ]; then
_dc_row FAIL state-root "$STATE_ROOT durable=$durable (selected by $_gstack_sr_var) is not writable" "fix its permissions, or set GSTACK_STATE_ROOT to a writable directory" COMPONENT_MISSING
return
fi
_dc_row PASS state-root "$STATE_ROOT durable=$durable (selected by $_gstack_sr_var; $note)"
}
# _dc_cfg KEY DEFAULT — a clamped config value, never the file or a message.
_dc_cfg() { local v; v=$("$BIN/gstack-config" get "$1" 2>/dev/null </dev/null | clamp 'a-z0-9_-' 24); printf '%s' "${v:-$2}"; }
_dc_check_privacy() {
_dc_row PASS privacy "telemetry=$(_dc_cfg telemetry off) artifacts_sync=$(_dc_cfg artifacts_sync_mode off) codex_reviews=$(_dc_cfg codex_reviews enabled) update_check=$(_dc_cfg update_check true)"
}
_dc_check_cores() {
local n
n=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 0)
n=$(printf '%s' "$n" | clamp '0-9' 6)
case "$n" in ''|0) _dc_row PASS cores "unknown CPU count"; return ;; esac
if [ "$n" -lt 8 ]; then
_dc_row PASS cores "$n (under 8: run full unit and E2E tiers on a remote gate, e.g. test_backend=ubicloud)"
else
_dc_row PASS cores "$n"
fi
}
_dc_check_revision() {
local installed head="" want="$CHECK_REVISION" target=""
installed=$(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)
head=$(git -C "$ROOT_REAL" rev-parse --short HEAD 2>/dev/null | clamp '0-9a-f' 12)
_dc_revision="${installed:-unknown}"
if [ -z "$want" ]; then
_dc_row PASS revision "${installed:-unknown}${head:+ ($head)}"
return
fi
if [ "$want" = "$installed" ] || [ "$want" = "v$installed" ]; then
_dc_row PASS revision "$installed${head:+ ($head)} matches --revision $want"
return
fi
target=$(git -C "$ROOT_REAL" rev-parse --verify --quiet "$want^{commit}" 2>/dev/null | clamp '0-9a-f' 40)
if [ -n "$target" ] && [ "$target" = "$(git -C "$ROOT_REAL" rev-parse HEAD 2>/dev/null)" ]; then
_dc_row PASS revision "$installed ($head) is --revision $want"
return
fi
_dc_row FAIL revision "installed ${installed:-unknown}${head:+ at $head} (requested $want)" "cd $SRC && git fetch origin && git checkout $want && ./setup, or run /gstack-upgrade" REVISION_UNMET
}
_dc_print_json() {
local status id detail fix code first=1 ok=false
[ -z "$_dc_failed" ] && ok=true
printf '{"schema_version":1,"revision":%s,"for":%s,"required":[' "$(_dc_json_str "$_dc_revision")" "$(_dc_json_str "${_dc_label#*=}")"
for id in $_dc_required; do
[ "$first" -eq 1 ] || printf ','
first=0
_dc_json_str "$id"
done
printf '],"ok":%s,"failed":[' "$ok"
first=1
for id in $(printf '%s' "$_dc_failed" | tr ',' ' '); do
[ "$first" -eq 1 ] || printf ','
first=0
_dc_json_str "$id"
done
printf '],"rows":['
first=1
while IFS=' ' read -r status id detail fix code; do
[ -n "$status" ] || continue
[ "$first" -eq 1 ] || printf ','
first=0
printf '{"id":%s,"status":%s,"detail":%s' "$(_dc_json_str "$id")" "$(_dc_json_str "$status")" "$(_dc_json_str "$detail")"
[ -n "$fix" ] && printf ',"fix":%s' "$(_dc_json_str "$fix")"
[ -n "$code" ] && printf ',"code":%s,"anchor":%s' "$(_dc_json_str "$code")" "$(_dc_json_str "$(gstack_result_anchor "$code")")"
printf '}'
done <<ROWS
$_dc_rows
ROWS
printf '],"trailer":%s}\n' "$(_dc_json_str "$_dc_trailer")"
}
# gstack_doctor_check — the entry point; returns 0 (ok) or 1 (fail).
gstack_doctor_check() {
local id
_dc_bun="" _dc_revision=""
_dc_resolve_required
[ "$CHECK_JSON" -eq 1 ] || echo "required: $_dc_required"
for id in $GSTACK_DOCTOR_COMPONENTS; do
case "$id" in
claude|codex) _dc_check_host "$id" ;;
patch) _dc_check_patch ;;
codex-cli) _dc_check_codex_cli ;;
browse-bundle) _dc_check_browse_bundle ;;
browser) _dc_check_browser ;;
cso) _dc_check_cso ;;
runtime) _dc_check_runtime ;;
pins) _dc_check_pins ;;
state-root) _dc_check_state_root ;;
privacy) _dc_check_privacy ;;
cores) _dc_check_cores ;;
revision) _dc_check_revision ;;
*) _dc_row FAIL "$id" "no check implemented for component '$id'" "report this gstack bug" COMPONENT_MISSING ;;
esac
done
if [ -n "$_dc_failed" ]; then
_dc_trailer="gstack: fail $_dc_failed"
else
_dc_trailer="gstack: ok $_dc_revision $_dc_label"
fi
if [ "$CHECK_JSON" -eq 1 ]; then _dc_print_json; else echo "$_dc_trailer"; fi
[ -z "$_dc_failed" ]
}
+109
View File
@@ -0,0 +1,109 @@
# shellcheck shell=bash
# gstack-doctor-components.sh — GENERATED from lib/doctor-components.ts and
# lib/result-codes.ts by scripts/gen-doctor-components.ts (bun run gen:skill-docs). Do not edit; the
# freshness test is test/doctor-components.test.ts. Sourced by
# bin/gstack-doctor-check.sh; Bash 3.2 builtins only.
GSTACK_DOCTOR_COMPONENTS="claude codex patch codex-cli browse-bundle browser cso runtime pins state-root privacy cores revision"
GSTACK_DOCTOR_CORE="runtime pins state-root privacy cores revision"
GSTACK_DOCTOR_HOSTS="claude codex"
GSTACK_DOCTOR_OPTIONAL="codex-cli cso"
GSTACK_DOCTOR_SKILLS="autoplan plan-ceo-review plan-eng-review plan-design-review plan-devex-review plan-tune office-hours spec review ship investigate codex cso browse qa qa-only design-review design-consultation design-html devex-review scrape skillify canary benchmark make-pdf diagram pair-agent open-gstack-browser setup-browser-cookies connect-chrome land-and-deploy benchmark-models careful claude-code context-restore context-save design-shotgun deslop-shared-libs document-generate document-release freeze gstack-upgrade guard health ios-clean ios-design-review ios-fix ios-qa ios-sync landing-report learn retro setup-deploy setup-gbrain sync-gbrain test-audit unfreeze"
GSTACK_BROWSER_LAZY_REASON="lazy; gstack-browser-ensure installs on first use"
# gstack_doctor_component_title ID — the human title; returns 1 for an unknown id.
gstack_doctor_component_title() {
case "$1" in
claude) printf '%s' 'Claude skills' ;;
codex) printf '%s' 'Codex skills' ;;
patch) printf '%s' 'Astra behavioral patch in the Codex render' ;;
codex-cli) printf '%s' 'Codex CLI (outside voice)' ;;
browse-bundle) printf '%s' 'browse bundle' ;;
browser) printf '%s' 'Chromium launch and render' ;;
cso) printf '%s' 'CSO native helper' ;;
runtime) printf '%s' 'gstack runtime (Bun)' ;;
pins) printf '%s' 'project pins' ;;
state-root) printf '%s' 'state root' ;;
privacy) printf '%s' 'privacy' ;;
cores) printf '%s' 'cores' ;;
revision) printf '%s' 'revision' ;;
*) return 1 ;;
esac
}
# gstack_doctor_component_kind ID — core | host | component | optional; returns 1 for an unknown id.
gstack_doctor_component_kind() {
case "$1" in
claude) printf '%s' 'host' ;;
codex) printf '%s' 'host' ;;
patch) printf '%s' 'component' ;;
codex-cli) printf '%s' 'optional' ;;
browse-bundle) printf '%s' 'component' ;;
browser) printf '%s' 'component' ;;
cso) printf '%s' 'optional' ;;
runtime) printf '%s' 'core' ;;
pins) printf '%s' 'core' ;;
state-root) printf '%s' 'core' ;;
privacy) printf '%s' 'core' ;;
cores) printf '%s' 'core' ;;
revision) printf '%s' 'core' ;;
*) return 1 ;;
esac
}
# gstack_doctor_skill_requires SKILL — the non-core ids the skill needs; returns 1 for an unknown skill.
gstack_doctor_skill_requires() {
case "$1" in
autoplan|plan-ceo-review|plan-eng-review|plan-design-review|plan-devex-review|plan-tune|office-hours|spec|review|ship|investigate) printf '%s' 'claude codex patch' ;;
codex) printf '%s' 'claude codex patch codex-cli' ;;
cso) printf '%s' 'claude cso' ;;
browse|qa|qa-only|design-review|design-consultation|design-html|devex-review|scrape|skillify|canary|benchmark|make-pdf|diagram|pair-agent|open-gstack-browser|setup-browser-cookies|connect-chrome|land-and-deploy) printf '%s' 'claude codex patch browse-bundle browser' ;;
benchmark-models|careful|claude-code|context-restore|context-save|design-shotgun|deslop-shared-libs|document-generate|document-release|freeze|gstack-upgrade|guard|health|ios-clean|ios-design-review|ios-fix|ios-qa|ios-sync|landing-report|learn|retro|setup-deploy|setup-gbrain|sync-gbrain|test-audit|unfreeze) printf '%s' 'claude' ;;
*) return 1 ;;
esac
}
# gstack_doctor_skill_optional SKILL — ids that SKIP when absent; returns 1 for an unknown skill.
gstack_doctor_skill_optional() {
case "$1" in
autoplan|plan-ceo-review|plan-eng-review|plan-design-review|plan-devex-review|plan-tune|office-hours|spec|review|ship|investigate) printf '%s' 'codex-cli' ;;
codex) printf '%s' '' ;;
cso) printf '%s' '' ;;
browse|qa|qa-only|design-review|design-consultation|design-html|devex-review|scrape|skillify|canary|benchmark|make-pdf|diagram|pair-agent|open-gstack-browser|setup-browser-cookies|connect-chrome|land-and-deploy) printf '%s' 'codex-cli' ;;
benchmark-models|careful|claude-code|context-restore|context-save|design-shotgun|deslop-shared-libs|document-generate|document-release|freeze|gstack-upgrade|guard|health|ios-clean|ios-design-review|ios-fix|ios-qa|ios-sync|landing-report|learn|retro|setup-deploy|setup-gbrain|sync-gbrain|test-audit|unfreeze) printf '%s' '' ;;
*) return 1 ;;
esac
}
# gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code.
gstack_result_anchor() {
case "$1" in
ARTIFACT_INVALID_JSON) printf '%s' 'artifact-invalid-json' ;;
ARTIFACT_UNSUPPORTED_VERSION) printf '%s' 'artifact-unsupported-version' ;;
ARTIFACT_SCHEMA) printf '%s' 'artifact-schema' ;;
ARTIFACT_MISSING) printf '%s' 'artifact-missing' ;;
ARTIFACT_STALE) printf '%s' 'artifact-stale' ;;
ARTIFACT_PATH_ESCAPE) printf '%s' 'artifact-path-escape' ;;
ARTIFACT_MALFORMED_JSONL) printf '%s' 'artifact-malformed-jsonl' ;;
ARTIFACT_DUPLICATE_ID) printf '%s' 'artifact-duplicate-id' ;;
ARTIFACT_UNBOUND_ID) printf '%s' 'artifact-unbound-id' ;;
ARTIFACT_DANGLING_REF) printf '%s' 'artifact-dangling-ref' ;;
ARTIFACT_DEPENDENCY_CYCLE) printf '%s' 'artifact-dependency-cycle' ;;
ARTIFACT_COUNT_MISMATCH) printf '%s' 'artifact-count-mismatch' ;;
ARTIFACT_RUN_INTERRUPTED) printf '%s' 'artifact-run-interrupted' ;;
ARTIFACT_REVIEWER_MISSING) printf '%s' 'artifact-reviewer-missing' ;;
ARTIFACT_GUARD_LINE_MISSING) printf '%s' 'artifact-guard-line-missing' ;;
GUARD_NOT_INSTALLED) printf '%s' 'guard-not-installed' ;;
REVIEW_STATUS_MISMATCH) printf '%s' 'review-status-mismatch' ;;
GATE_REV_STALE) printf '%s' 'gate-rev-stale' ;;
GATE_REPLY_UNPARSED) printf '%s' 'gate-reply-unparsed' ;;
COMPONENT_MISSING) printf '%s' 'doctor-component-missing' ;;
RUNTIME_BELOW_MINIMUM) printf '%s' 'doctor-runtime-below-minimum' ;;
PROJECT_PIN_MISMATCH) printf '%s' 'doctor-project-pin-mismatch' ;;
REVISION_UNMET) printf '%s' 'doctor-revision-unmet' ;;
RUNTIME_PIN_CONFLICT) printf '%s' 'capy-install-runtime-conflict' ;;
BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;;
INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;;
*) return 1 ;;
esac
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bun
/**
* gstack-runtime-pins — the doctor's `project pins` row (lib/runtime-pins.ts).
*
* gstack-runtime-pins --project <dir> [--bun <v>] [--node <v>] [--platform linux|darwin|win32] [--json]
*
* Prints one PIN: line per declared pin (tool, constraint, exact|range,
* file:line, lane), PIN_SKIP: for pins it cannot evaluate, PIN_FAIL: per
* failing tool, then `PINS_VERDICT: pass|fail|none`. --json prints the report
* object instead. Exit 0 on pass or none, 1 on fail, 2 on usage.
*/
import * as fs from 'node:fs';
import { collectPins, evaluatePins, renderPinLines, type Running } from '../lib/runtime-pins';
const args = process.argv.slice(2);
let project = '';
let json = false;
let platform: NodeJS.Platform = process.platform;
const running: Running = {};
const usage = () => { console.error('usage: gstack-runtime-pins --project <dir> [--bun <v>] [--node <v>] [--platform linux|darwin|win32] [--json]'); process.exit(2); };
for (let i = 0; i < args.length; i++) {
const next = () => { if (i + 1 >= args.length) usage(); return args[++i]; };
switch (args[i]) {
case '--project': project = next(); break;
case '--bun': running.bun = next(); break;
case '--node': running.node = next(); break;
case '--platform': platform = next() as NodeJS.Platform; break;
case '--json': json = true; break;
case '-h': case '--help': console.log('usage: gstack-runtime-pins --project <dir> [--bun <v>] [--node <v>] [--platform linux|darwin|win32] [--json]'); process.exit(0);
default: usage();
}
}
if (!project || !fs.existsSync(project)) usage();
const report = evaluatePins(collectPins(project), running, platform);
if (json) console.log(JSON.stringify({ schema_version: 1, project, running, platform, ...report }));
else for (const line of renderPinLines(report, platform)) console.log(line);
process.exit(report.verdict === 'fail' ? 1 : 0);