mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
feat: gstack-doctor readiness report and setup --status Codex row (B5)
bin/gstack-doctor prints one row per check without starting a skill: install root (the registry rows behind ./setup --status), state root, Bun against the floor, hook parse check, Codex mode and self-locate via gstack-codex-probe subcommands, the cached model probe with its age (--live runs the paid probe-model), artifacts-sync health from the status file with the status code clamped, the browse bundle, Claude Code version, the largest session journal and the last five schema-1 guard reason codes. Rows are ok, warn, not configured or fail; only fail exits non-zero; fix lines name helpers by absolute install-root path. ./setup --status gains a read-only Codex row (bin/gstack-codex-status.sh, shared with the doctor) and ends with the doctor's absolute path. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8aae497d2b
commit
a9f2ff6374
4 files changed
+810
-2
No files matched your search
@@ -0,0 +1,74 @@
|
||||
# shellcheck shell=bash
|
||||
# gstack-codex-status.sh — free, read-only Codex facts shared by
|
||||
# `./setup --status` and bin/gstack-doctor. Sourced, never executed. Bash 3.2
|
||||
# builtins plus head/tr/date; it never runs a model, never writes, and never
|
||||
# needs bun, so `./setup --status` stays "no bun, no build, no writes".
|
||||
#
|
||||
# The model-probe cache is <state root>/.codex-model-probe, written only by
|
||||
# bin/gstack-codex-probe: first line "STATUS EPOCH SIGNATURE". Only the status
|
||||
# word and the epoch are read here; the signature check (and so whether the
|
||||
# entry would still be reused) stays the probe's own logic.
|
||||
|
||||
# gstack_codex_cache_read STATE_ROOT — sets _gcx_probe to MODEL_OK,
|
||||
# MODEL_UNUSABLE, MODEL_QUOTA_EXHAUSTED, unrecognized, or "" (never probed),
|
||||
# and _gcx_age to the entry's age in seconds ("" when the epoch is unusable).
|
||||
gstack_codex_cache_read() {
|
||||
_gcx_probe="" _gcx_age=""
|
||||
_gcx_line=""
|
||||
[ -f "$1/.codex-model-probe" ] && _gcx_line=$(head -1 "$1/.codex-model-probe" 2>/dev/null)
|
||||
[ -n "$_gcx_line" ] || return 0
|
||||
_gcx_status="${_gcx_line%% *}"
|
||||
_gcx_ts="${_gcx_line#* }"; _gcx_ts="${_gcx_ts%% *}"
|
||||
case "$_gcx_status" in
|
||||
MODEL_OK|MODEL_UNUSABLE|MODEL_QUOTA_EXHAUSTED) _gcx_probe="$_gcx_status" ;;
|
||||
*) _gcx_probe=unrecognized ;;
|
||||
esac
|
||||
case "$_gcx_ts" in ''|*[!0-9]*) return 0 ;; esac
|
||||
_gcx_now=$(date +%s 2>/dev/null)
|
||||
case "$_gcx_now" in ''|*[!0-9]*) return 0 ;; esac
|
||||
[ "$_gcx_ts" -le "$_gcx_now" ] && _gcx_age=$((_gcx_now - _gcx_ts))
|
||||
return 0
|
||||
}
|
||||
|
||||
# gstack_codex_age_text SECONDS — "40s ago", "12m ago", "3h ago", "2d ago";
|
||||
# "age unknown" when SECONDS is empty.
|
||||
gstack_codex_age_text() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) printf 'age unknown' ;;
|
||||
*) if [ "$1" -lt 60 ]; then printf '%ss ago' "$1"
|
||||
elif [ "$1" -lt 3600 ]; then printf '%sm ago' "$(($1 / 60))"
|
||||
elif [ "$1" -lt 172800 ]; then printf '%sh ago' "$(($1 / 3600))"
|
||||
else printf '%sd ago' "$(($1 / 86400))"; fi ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# gstack_codex_version — sets _gcx_version to the first line of
|
||||
# `codex --version`, clamped to a version-like charset and 60 characters;
|
||||
# "" when codex is absent or prints nothing. Returns codex's exit code.
|
||||
gstack_codex_version() {
|
||||
_gcx_version=""
|
||||
command -v codex >/dev/null 2>&1 || return 127
|
||||
_gcx_vout=$(codex --version </dev/null 2>/dev/null)
|
||||
_gcx_vrc=$?
|
||||
_gcx_version=$(printf '%s\n' "$_gcx_vout" | head -1 | tr -cd 'A-Za-z0-9 ._+()-' | cut -c1-60)
|
||||
return "$_gcx_vrc"
|
||||
}
|
||||
|
||||
# gstack_codex_status_line GSTACK_ROOT STATE_ROOT — the `./setup --status`
|
||||
# Codex row: install and version, the codex_reviews setting, and the cached
|
||||
# model probe with its age. No paid call.
|
||||
gstack_codex_status_line() {
|
||||
_gcx_reviews=$("$1/bin/gstack-config" get codex_reviews 2>/dev/null || echo enabled)
|
||||
case "$_gcx_reviews" in enabled|disabled) ;; *) _gcx_reviews=unknown ;; esac
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
printf 'Codex: not installed (codex_reviews=%s; outside reviews in /review, /ship, /autoplan and /codex need it)\n' "$_gcx_reviews"
|
||||
return 0
|
||||
fi
|
||||
gstack_codex_version
|
||||
gstack_codex_cache_read "$2"
|
||||
case "$_gcx_probe" in
|
||||
'') _gcx_probe_text="not probed" ;;
|
||||
*) _gcx_probe_text="$_gcx_probe $(gstack_codex_age_text "$_gcx_age")" ;;
|
||||
esac
|
||||
printf 'Codex: installed (%s); codex_reviews=%s; model probe: %s\n' "${_gcx_version:-version unknown}" "$_gcx_reviews" "$_gcx_probe_text"
|
||||
}
|
||||
Executable
+392
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-doctor — readiness report without starting a skill.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-doctor print one row per check; spends nothing
|
||||
# gstack-doctor --live also run the paid Codex model probe (one short call)
|
||||
#
|
||||
# Rows: install root, state root, bun, hooks, codex, codex probe, artifacts
|
||||
# sync, browse bundle, claude code, session journal, guard log. Each row is
|
||||
# `ok`, `warn`, `not configured` or `fail`; a row that is not ok carries a fix
|
||||
# line, and every gstack helper in a fix line is an absolute path under this
|
||||
# install root. Exit 1 when any row is `fail`, 2 on a usage error, else 0.
|
||||
#
|
||||
# Read-only. Each row reuses the owner's check instead of new probe logic: the
|
||||
# install registry rows behind `./setup --status`, bin/gstack-bun-version.sh,
|
||||
# bin/gstack-hook-check, bin/gstack-codex-probe's subcommands (the paid
|
||||
# `probe-model` only with --live; otherwise the cached result and its age),
|
||||
# the status file bin/gstack-brain-sync writes, and setup's browse build
|
||||
# outputs. The sync row prints only a clamped status code and counts, never
|
||||
# the status message or file paths: the message can quote a matched secret.
|
||||
# Bash 3.2 plus POSIX tools; it still reports when bun or python3 is missing.
|
||||
|
||||
# Heredoc delivery guard, as in bin/gstack-brain-sync: compat level 50 keeps
|
||||
# heredoc bodies on the tempfile path, so a small pipe buffer cannot hang them.
|
||||
BASH_COMPAT=50
|
||||
|
||||
set -u
|
||||
|
||||
usage() {
|
||||
sed -n '3,6p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
LIVE=0
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--live) LIVE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "gstack-doctor: unknown argument '$_arg'" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)" || { echo "gstack-doctor: cannot resolve the install root" >&2; exit 1; }
|
||||
ROOT_REAL="$(cd "$ROOT" && pwd -P)"
|
||||
BIN="$ROOT/bin"
|
||||
SRC="$ROOT_REAL"
|
||||
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
|
||||
CODEX_FEATURES="outside reviews in /review, /ship, /autoplan and /codex"
|
||||
N_OK=0 N_WARN=0 N_NC=0 N_FAIL=0
|
||||
|
||||
row() {
|
||||
printf '%-15s %-15s %s\n' "$1" "$2" "$3"
|
||||
[ -n "${4:-}" ] && printf '%32sfix: %s\n' '' "$4"
|
||||
case "$1" in
|
||||
ok) N_OK=$((N_OK + 1)) ;;
|
||||
warn) N_WARN=$((N_WARN + 1)) ;;
|
||||
'not configured') N_NC=$((N_NC + 1)) ;;
|
||||
*) N_FAIL=$((N_FAIL + 1)) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# clamp CHARSET MAX — stdin's first line reduced to CHARSET, at most MAX chars.
|
||||
clamp() { head -1 | tr -cd "$1" | cut -c1-"$2"; }
|
||||
|
||||
# epoch_of VALUE — epoch seconds for an epoch or an ISO-8601 UTC time; "" otherwise.
|
||||
epoch_of() {
|
||||
local v
|
||||
v=$(printf '%s' "$1" | tr -cd '0-9TZ:.+-')
|
||||
case "$v" in
|
||||
'') return 0 ;;
|
||||
*[!0-9]*) date -u -d "$v" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$v" +%s 2>/dev/null || true ;;
|
||||
*) printf '%s' "$v" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# json_field KEY JSON — a top-level string or number value from one-line JSON.
|
||||
# A key quoted inside a string value is escaped (\"key\"), so it never matches.
|
||||
json_field() {
|
||||
local v
|
||||
v=$(printf '%s' "$2" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"\\\\]*\)\".*/\1/p")
|
||||
[ -n "$v" ] || v=$(printf '%s' "$2" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p")
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
check_state_root() {
|
||||
if [ -d "$STATE_ROOT" ]; then
|
||||
row ok "state root" "$STATE_ROOT (selected by $_gstack_sr_var)"
|
||||
else
|
||||
row warn "state root" "$STATE_ROOT (selected by $_gstack_sr_var) does not exist yet" "cd $SRC && ./setup, or run any gstack skill"
|
||||
fi
|
||||
}
|
||||
|
||||
check_install() {
|
||||
local rows host scope src dest reg cur result note real installed="" bad="" badnote="" other="" total=0
|
||||
if ! . "$BIN/gstack-install-registry.sh" 2>/dev/null; then
|
||||
row fail "install root" "$BIN/gstack-install-registry.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack, then run its ./setup"
|
||||
return
|
||||
fi
|
||||
rows="$(_gstack_status_rows)"
|
||||
while IFS=' ' read -r host scope src dest reg cur result note; do
|
||||
[ -n "$host" ] || continue
|
||||
total=$((total + 1))
|
||||
real="$(cd "$src" 2>/dev/null && pwd -P)"
|
||||
if [ "$real" != "$ROOT_REAL" ] && [ "$(cd "$dest/gstack" 2>/dev/null && pwd -P)" != "$ROOT_REAL" ]; then
|
||||
[ -n "$other" ] || other="$src"
|
||||
continue
|
||||
fi
|
||||
[ -n "$real" ] && SRC="$real"
|
||||
case "$result" in
|
||||
current) installed="${installed:+$installed, }$host $scope current ($cur)" ;;
|
||||
*) bad="${bad:+$bad, }$host $scope $result"; [ -n "$badnote" ] || badnote="$note" ;;
|
||||
esac
|
||||
done <<ROWS
|
||||
$rows
|
||||
ROWS
|
||||
if [ -n "$bad" ]; then
|
||||
row warn "install root" "$ROOT: $bad${installed:+; $installed}" "${badnote:-cd $SRC && ./setup}"
|
||||
elif [ -n "$installed" ]; then
|
||||
row ok "install root" "$ROOT: $installed"
|
||||
elif [ "$total" -eq 0 ]; then
|
||||
row fail "install root" "$ROOT: no gstack install is registered" "cd $SRC && ./setup"
|
||||
else
|
||||
row warn "install root" "$ROOT is not the source of any of the $total registered install(s); this doctor checks this checkout" "cd $SRC && ./setup, or run $other/bin/gstack-doctor"
|
||||
fi
|
||||
}
|
||||
|
||||
check_bun() {
|
||||
local found where
|
||||
if ! . "$BIN/gstack-bun-version.sh" 2>/dev/null; then
|
||||
row fail bun "$BIN/gstack-bun-version.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack"
|
||||
return
|
||||
fi
|
||||
if ! where=$(command -v bun 2>/dev/null); then
|
||||
row fail bun "not found on PATH; gstack's skills, hooks and builds need it" "install Bun $GSTACK_BUN_TESTED (https://bun.sh), then cd $SRC && ./setup"
|
||||
return
|
||||
fi
|
||||
found=$(bun --version </dev/null 2>/dev/null | clamp 'A-Za-z0-9.+-' 40)
|
||||
case "$(gstack_bun_status "$found")" in
|
||||
ok) row ok bun "$found at $where (floor $GSTACK_BUN_FLOOR, tested $GSTACK_BUN_TESTED)" ;;
|
||||
untested) row warn bun "$found at $where is below the tested $GSTACK_BUN_TESTED" "bun upgrade ($DOCS#bun-too-old)" ;;
|
||||
too-old) row fail bun "$found at $where is below the security floor $GSTACK_BUN_FLOOR" "bun upgrade, then cd $SRC && ./setup ($DOCS#bun-too-old)" ;;
|
||||
*) row warn bun "could not read the version of $where" "bun upgrade ($DOCS#bun-too-old)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_hooks() {
|
||||
local out rc checked missing first
|
||||
out=$("$BIN/gstack-hook-check" "$SRC" </dev/null 2>&1)
|
||||
rc=$?
|
||||
checked=$(printf '%s\n' "$out" | grep -c '^ok ')
|
||||
missing=$(printf '%s\n' "$out" | grep -c '^missing ')
|
||||
case "$rc" in
|
||||
0) if [ "$missing" -gt 0 ]; then row ok hooks "$checked hook(s) parse; $missing not installed"; else row ok hooks "$checked hook(s) parse"; fi ;;
|
||||
1) first=$(printf '%s\n' "$out" | grep '^fail ' | head -1 | cut -c6-300)
|
||||
row fail hooks "$first" "repair the file (git -C $SRC status shows half-applied edits), then cd $SRC && ./setup ($DOCS#setup-hook-does-not-parse)" ;;
|
||||
3) row warn hooks "TypeScript hook entries not checked: bun not found" "install Bun (see the bun row), then rerun $BIN/gstack-doctor" ;;
|
||||
*) row fail hooks "the hook check could not run (exit $rc)" "cd $SRC && ./setup" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# check_codex — CODEX_MODE from the free checks; sets CODEX_READY=1 when the
|
||||
# probe row should follow.
|
||||
check_codex() {
|
||||
local reviews probe="$BIN/gstack-codex-probe" sel rc model src
|
||||
CODEX_READY=0
|
||||
reviews=$("$BIN/gstack-config" get codex_reviews 2>/dev/null || echo enabled)
|
||||
if [ "$reviews" = disabled ]; then
|
||||
row "not configured" codex "codex_reviews disabled; $CODEX_FEATURES skip Codex" "$BIN/gstack-config set codex_reviews enabled"
|
||||
return
|
||||
fi
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
row "not configured" codex "Codex CLI not installed; $CODEX_FEATURES have no Codex coverage" "npm install -g @openai/codex, then codex login"
|
||||
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
|
||||
[ "$rc" -ne 0 ] && rc="exited $rc" || rc="printed no CODEX_SEL line"
|
||||
row fail codex "CODEX_MODE helper_unavailable: select-model $rc (the probe could not locate its scripts, or the requested model id is invalid)" "run $probe select-model exec to see the cause; reinstall with cd $SRC && ./setup"
|
||||
return
|
||||
fi
|
||||
src=$(printf '%s\n' "$sel" | sed -n 's/^CODEX_MODEL: .*source: \(.*\))$/\1/p' | clamp 'A-Za-z0-9 ._:/=-' 80)
|
||||
gstack_codex_version
|
||||
if ! "$probe" check-auth </dev/null >/dev/null 2>&1; then
|
||||
row warn codex "CODEX_MODE not_authed; model $model (source: ${src:-unknown})" "codex login, or export CODEX_API_KEY"
|
||||
return
|
||||
fi
|
||||
if ! "$probe" check-sandbox </dev/null >/dev/null 2>&1; then
|
||||
row warn codex "CODEX_MODE sandbox_unavailable; no paid call runs" "run $probe check-sandbox for the reason and fix"
|
||||
return
|
||||
fi
|
||||
if "$probe" check-version </dev/null 2>&1 | grep -q '^WARN'; then
|
||||
row warn codex "${_gcx_version:-version unknown} has a known problem; model $model (source: ${src:-unknown})" "run $probe check-version for the fix"
|
||||
else
|
||||
row ok codex "${_gcx_version:-version unknown}; self-locate ok; model $model (source: ${src:-unknown})"
|
||||
fi
|
||||
CODEX_READY=1
|
||||
}
|
||||
|
||||
check_codex_probe() {
|
||||
local out rc state probe="$BIN/gstack-codex-probe" model_fix="set GSTACK_CODEX_MODEL=<supported-model> or model in config.toml, then $BIN/gstack-doctor --live"
|
||||
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) case "$state" in
|
||||
inconclusive|rate_limited) row warn "codex probe" "live probe: CODEX_MODE unverified ($state)" "rerun $BIN/gstack-doctor --live later" ;;
|
||||
*) row ok "codex probe" "live probe: ok" ;;
|
||||
esac ;;
|
||||
1) row warn "codex probe" "live probe: model_unusable; $CODEX_FEATURES skip Codex" "$model_fix" ;;
|
||||
2) row fail "codex probe" "live probe: broken_install (the Codex CLI cannot execute)" "npm install -g @openai/codex" ;;
|
||||
4) row warn "codex probe" "live probe: quota_exhausted (the account's Codex usage limit)" "wait for the reset time Codex names: $probe probe-model exec" ;;
|
||||
*) row warn "codex probe" "live probe: exit $rc${state:+ ($state)}" "rerun $BIN/gstack-doctor --live" ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
gstack_codex_cache_read "$STATE_ROOT"
|
||||
local age
|
||||
age=$(gstack_codex_age_text "$_gcx_age")
|
||||
case "$_gcx_probe" in
|
||||
'') row warn "codex probe" "not probed; Codex readiness is unverified until a skill or --live runs it" "$BIN/gstack-doctor --live (one short paid Codex call)" ;;
|
||||
MODEL_OK) row ok "codex probe" "cached MODEL_OK, $age (not re-run; --live probes now)" ;;
|
||||
MODEL_UNUSABLE) row warn "codex probe" "cached MODEL_UNUSABLE, $age; $CODEX_FEATURES skip Codex" "$model_fix" ;;
|
||||
MODEL_QUOTA_EXHAUSTED) row warn "codex probe" "cached MODEL_QUOTA_EXHAUSTED, $age (the account's Codex usage limit)" "wait for the reset, then $BIN/gstack-doctor --live" ;;
|
||||
*) row warn "codex probe" "cached entry unrecognized, $age" "$BIN/gstack-doctor --live" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_sync() {
|
||||
local mode file json status held drainable drain push queue=0 now f detail fix="$BIN/gstack-brain-sync --status"
|
||||
mode=$("$BIN/gstack-config" get artifacts_sync_mode 2>/dev/null || echo off)
|
||||
if [ ! -d "$STATE_ROOT/.git" ]; then
|
||||
row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init"
|
||||
return
|
||||
fi
|
||||
if [ "$mode" = off ]; then
|
||||
row "not configured" "artifacts sync" "artifacts_sync_mode off; cross-machine artifacts sync is unavailable" "$BIN/gstack-config set artifacts_sync_mode artifacts-only"
|
||||
return
|
||||
fi
|
||||
for f in "$STATE_ROOT/.brain-queue.d"/*.json; do [ -f "$f" ] && queue=$((queue + 1)); done
|
||||
for f in "$STATE_ROOT/.brain-queue.jsonl" "$STATE_ROOT/.brain-queue.jsonl.migrating"; do
|
||||
[ -f "$f" ] && queue=$((queue + $(wc -l < "$f" | tr -d ' ')))
|
||||
done
|
||||
file="$STATE_ROOT/.brain-sync-status.json"
|
||||
if [ ! -f "$file" ]; then
|
||||
if [ "$queue" -gt 0 ]; then row warn "artifacts sync" "no drain recorded while $queue record(s) are queued" "$fix"
|
||||
else row ok "artifacts sync" "mode $mode; no drain yet, queue empty"; fi
|
||||
return
|
||||
fi
|
||||
json=$(head -c 16384 "$file" 2>/dev/null | tr '\n\r' ' ')
|
||||
status=$(json_field status "$json")
|
||||
case "$status" in ok|idle|held|blocked|push_failed|error) ;; *) status=unknown ;; esac
|
||||
held=$(json_field held "$json" | tr -cd '0-9')
|
||||
drainable=$(json_field drainable "$json" | tr -cd '0-9')
|
||||
drain=$(epoch_of "$(json_field last_drain_at "$json")")
|
||||
push=$(epoch_of "$(json_field last_push_at "$json")")
|
||||
[ -n "$push" ] || push=$(epoch_of "$(head -1 "$STATE_ROOT/.brain-last-push" 2>/dev/null)")
|
||||
now=$(date +%s)
|
||||
case "$status" in
|
||||
held) row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"; return ;;
|
||||
blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;;
|
||||
push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;;
|
||||
esac
|
||||
if [ "${drainable:-0}" -gt 0 ] && { [ -z "$push" ] || [ $((now - push)) -gt 86400 ]; }; then
|
||||
row warn "artifacts sync" "status $status; no push for 24h while $drainable record(s) wait" "$fix"
|
||||
elif [ "$queue" -gt 0 ] && [ -n "$drain" ] && [ $((now - drain)) -gt 86400 ]; then
|
||||
row warn "artifacts sync" "status $status; no drain for 24h while $queue record(s) are queued" "$fix"
|
||||
else
|
||||
detail="status $status (mode $mode; queue $queue)"
|
||||
row ok "artifacts sync" "$detail"
|
||||
fi
|
||||
}
|
||||
|
||||
check_browse() {
|
||||
local exe="" missing="" f
|
||||
case "${OSTYPE:-}" in msys*|cygwin*|win32*) exe=".exe" ;; esac
|
||||
if [ ! -x "$ROOT/browse/dist/browse$exe" ]; then
|
||||
row warn "browse bundle" "not built; the gstack browser fallback for /browse, /qa, /qa-only, /design-review, /make-pdf, /pair-agent is unavailable (the Aside browser is unaffected)" "cd $SRC && ./setup"
|
||||
return
|
||||
fi
|
||||
for f in server-node.mjs .build-complete; do [ -e "$ROOT/browse/dist/$f" ] || missing="${missing:+$missing, }$f"; done
|
||||
if [ -n "$missing" ]; then
|
||||
row warn "browse bundle" "incomplete build (missing browse/dist: $missing)" "cd $SRC && ./setup"
|
||||
else
|
||||
row ok "browse bundle" "browse/dist built"
|
||||
fi
|
||||
}
|
||||
|
||||
check_claude() {
|
||||
local override cmd raw ver rc a
|
||||
local args=()
|
||||
override="${GSTACK_CLAUDE_BIN:-${CLAUDE_BIN:-}}"
|
||||
override="${override#\"}"; override="${override%\"}"
|
||||
if [ -n "$override" ]; then
|
||||
case "$override" in /*|[A-Za-z]:*) cmd="$override" ;; *) cmd=$(command -v "$override" 2>/dev/null) ;; esac
|
||||
raw="${GSTACK_CLAUDE_BIN_ARGS:-${CLAUDE_BIN_ARGS:-}}"
|
||||
case "$raw" in
|
||||
'') ;;
|
||||
'['*) raw="${raw#[}"; raw="${raw%]}"
|
||||
while IFS= read -r a; do
|
||||
a=$(printf '%s' "$a" | sed 's/^[[:space:]]*"\{0,1\}//; s/"\{0,1\}[[:space:]]*$//')
|
||||
[ -n "$a" ] && args+=("$a")
|
||||
done <<ARGS
|
||||
$(printf '%s' "$raw" | tr ',' '\n')
|
||||
ARGS
|
||||
;;
|
||||
*) raw="${raw#\"}"; args+=("${raw%\"}") ;;
|
||||
esac
|
||||
else
|
||||
cmd=$(command -v claude 2>/dev/null)
|
||||
fi
|
||||
if [ -z "${cmd:-}" ]; then
|
||||
row "not configured" "claude code" "Claude Code CLI not found; the Claude Code host and Claude outside reviews are unavailable" "install Claude Code (https://code.claude.com), or set GSTACK_CLAUDE_BIN"
|
||||
return
|
||||
fi
|
||||
ver=$("$cmd" ${args[@]+"${args[@]}"} --version </dev/null 2>/dev/null)
|
||||
rc=$?
|
||||
ver=$(printf '%s\n' "$ver" | clamp 'A-Za-z0-9 ._()+-' 60)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
row warn "claude code" "$cmd --version failed (exit $rc)" "reinstall Claude Code (https://code.claude.com)"
|
||||
else
|
||||
row ok "claude code" "${ver:-version unknown} at $cmd"
|
||||
fi
|
||||
}
|
||||
|
||||
check_journal() {
|
||||
local cwd dir f size largest=0 count=0
|
||||
case "${OSTYPE:-}" in msys*|cygwin*) cwd=$(pwd -W 2>/dev/null || pwd -P) ;; *) cwd=$(pwd -P) ;; esac
|
||||
dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')"
|
||||
for f in "$dir"/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
count=$((count + 1))
|
||||
size=$(wc -c < "$f" | tr -d ' ')
|
||||
[ "$size" -gt "$largest" ] && largest="$size"
|
||||
done
|
||||
if [ "$count" -eq 0 ]; then
|
||||
row ok "session journal" "no session journals for this project"
|
||||
return
|
||||
fi
|
||||
row ok "session journal" "largest of $count: $(awk -v b="$largest" 'BEGIN { if (b >= 1048576) printf "%.1f MiB", b / 1048576; else if (b >= 1024) printf "%.1f KiB", b / 1024; else printf "%d B", b }')"
|
||||
}
|
||||
|
||||
check_guard_log() {
|
||||
local log="$STATE_ROOT/analytics/autoplan-guard.jsonl" summary
|
||||
if [ ! -f "$log" ]; then
|
||||
row ok "guard log" "no guard decisions logged"
|
||||
return
|
||||
fi
|
||||
summary=$(awk '
|
||||
!/^\{/ || !match($0, /"schema"[[:space:]]*:[[:space:]]*1[,}[:space:]]/) { next }
|
||||
match($0, /"decision"[[:space:]]*:[[:space:]]*"(allow|deny)"/) {
|
||||
n++; if (substr($0, RSTART, RLENGTH) ~ /deny/) d++
|
||||
}
|
||||
match($0, /"reason_code"[[:space:]]*:[[:space:]]*"[a-z0-9_]+"/) {
|
||||
c = substr($0, RSTART, RLENGTH); sub(/^[^:]*:[[:space:]]*"/, "", c); sub(/"$/, "", c)
|
||||
codes[k++ % 5] = c
|
||||
}
|
||||
END {
|
||||
out = (n + 0) " decisions (" (d + 0) " denied)"
|
||||
if (k > 0) {
|
||||
out = out "; last codes:"; sep = " "
|
||||
for (i = (k > 5 ? k - 5 : 0); i < k; i++) { out = out sep codes[i % 5]; sep = ", " }
|
||||
}
|
||||
print out
|
||||
}' "$log" 2>/dev/null)
|
||||
row ok "guard log" "${summary:-0 decisions (0 denied)}"
|
||||
}
|
||||
|
||||
if ! . "$BIN/gstack-state-root.sh" 2>/dev/null; then
|
||||
row fail "state root" "$BIN/gstack-state-root.sh is missing" "reinstall gstack from a fresh checkout of https://github.com/garrytan/gstack, then run its ./setup"
|
||||
exit 1
|
||||
fi
|
||||
gstack_state_root_select
|
||||
STATE_ROOT="$_gstack_sr_root"
|
||||
GSTACK_STATE_ROOT="$STATE_ROOT"
|
||||
. "$BIN/gstack-codex-status.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-codex-status.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
|
||||
|
||||
echo "gstack doctor $(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)"
|
||||
check_install
|
||||
check_state_root
|
||||
check_bun
|
||||
check_hooks
|
||||
check_codex
|
||||
[ "$CODEX_READY" -eq 1 ] && check_codex_probe
|
||||
check_sync
|
||||
check_browse
|
||||
check_claude
|
||||
check_journal
|
||||
check_guard_log
|
||||
echo "$N_OK ok, $N_WARN warn, $N_NC not configured, $N_FAIL fail"
|
||||
[ "$N_FAIL" -eq 0 ]
|
||||
@@ -32,7 +32,8 @@ Options:
|
||||
--no-prefix Install skills with short names (e.g. /review). Default.
|
||||
--team Switch to team mode (per-repo gstack with auto-update).
|
||||
--no-team Force solo install even if a team-mode repo is detected.
|
||||
--status Print one row per gstack install (read-only) and exit.
|
||||
--status Print one row per gstack install, a Codex row and the
|
||||
path to gstack-doctor (read-only), then exit.
|
||||
--refresh-registered
|
||||
Refresh every install registered for this checkout (what
|
||||
/gstack-upgrade runs), then print one row per install.
|
||||
@@ -135,11 +136,13 @@ for _arg in "$@"; do
|
||||
--status)
|
||||
_STATUS_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
. "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" \
|
||||
&& . "$_STATUS_DIR/bin/gstack-render-claude.sh" || exit 1
|
||||
&& . "$_STATUS_DIR/bin/gstack-render-claude.sh" && . "$_STATUS_DIR/bin/gstack-codex-status.sh" || exit 1
|
||||
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
|
||||
gstack_install_status
|
||||
_status_install_checks
|
||||
gstack_claude_overlay_status
|
||||
gstack_codex_status_line "$_STATUS_DIR" "$GSTACK_STATE_ROOT"
|
||||
echo "Readiness check: $(cd "$(dirname "$0")" && pwd)/bin/gstack-doctor"
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* gstack-doctor: one row per readiness check, each ok / warn / not configured
|
||||
* / fail with a fix line when not ok; only fail makes the exit non-zero; fix
|
||||
* lines name helpers by absolute install-root path; the paid Codex model probe
|
||||
* runs only with --live. Also pins the `./setup --status` Codex row and its
|
||||
* trailing doctor path. Every run uses a fixture install root (copies of the
|
||||
* real helpers plus a stub gstack-codex-probe), a fixture state root, stub
|
||||
* bun/codex/claude on PATH and an explicit HOME. Scripts run through explicit
|
||||
* bash argv, so the file is in the curated Windows lane.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const COPIED = [
|
||||
'setup', 'VERSION',
|
||||
'bin/gstack-doctor', 'bin/gstack-codex-status.sh', 'bin/gstack-state-root.sh', 'bin/gstack-install-registry.sh',
|
||||
'bin/gstack-render-claude.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-hook-check', 'bin/gstack-config',
|
||||
];
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, text, { mode });
|
||||
};
|
||||
const script = (file: string, lines: string[]) => write(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', 0o755);
|
||||
|
||||
/** The fixture's PATH entries minus every directory holding a real bun, codex or claude. */
|
||||
function hostPath(base: string): string {
|
||||
const names = ['bun', 'codex', 'claude'];
|
||||
const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.ps1'] : [''];
|
||||
return (process.env.PATH ?? process.env.Path ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => {
|
||||
if (!names.some(n => exts.some(e => fs.existsSync(path.join(dir, n + e))))) return dir;
|
||||
const shim = path.join(base, `host-${i}`);
|
||||
fs.mkdirSync(shim, { recursive: true });
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (names.some(n => exts.some(e => name === n + e))) continue;
|
||||
try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable entry */ }
|
||||
}
|
||||
return shim;
|
||||
}).join(path.delimiter);
|
||||
}
|
||||
|
||||
interface Fixture { base: string; root: string; state: string; home: string; work: string; stub: string; probeLog: string; env: Record<string, string> }
|
||||
|
||||
function makeFixture(): Fixture {
|
||||
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-doctor-')));
|
||||
bases.push(base);
|
||||
const root = path.join(base, 'gstack');
|
||||
const state = path.join(base, 'state');
|
||||
const home = path.join(base, 'home');
|
||||
const work = path.join(base, 'work');
|
||||
const stub = path.join(base, 'stub');
|
||||
const probeLog = path.join(base, 'probe-calls');
|
||||
for (const dir of [state, home, work, stub]) fs.mkdirSync(dir, { recursive: true });
|
||||
for (const rel of COPIED) {
|
||||
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);
|
||||
}
|
||||
script(path.join(root, 'bin/gstack-codex-probe'), [
|
||||
'echo "$*" >> "$DOCTOR_PROBE_LOG"',
|
||||
'case "$1" in',
|
||||
' select-model) [ "${STUB_SELECT_FAIL:-}" = 1 ] && { echo "gstack-codex-probe: cannot find its scripts dir" >&2; exit 1; }',
|
||||
' echo "CODEX_SEL: gpt-stub-5"; echo "CODEX_SEL_KIND: $2"; echo "CODEX_MODEL: gpt-stub-5 ($2; source: codex config.toml)" >&2 ;;',
|
||||
' check-auth) [ "${STUB_AUTH_FAIL:-}" = 1 ] && { echo AUTH_FAILED; exit 1; }; echo AUTH_OK ;;',
|
||||
' show-sandbox) echo "CODEX_SANDBOX: read-only" ;;',
|
||||
' check-sandbox|check-version) ;;',
|
||||
' probe-model) echo "CODEX_PROBE_STATE: ${STUB_PROBE_STATE:-ok}"; exit "${STUB_PROBE_EXIT:-0}" ;;',
|
||||
' *) exit 64 ;;',
|
||||
'esac',
|
||||
]);
|
||||
const hooks = spawnSync('bash', [path.join(root, 'bin/gstack-hook-check'), '--list', root], { encoding: 'utf8', timeout: 20_000 }).stdout.trim().split('\n');
|
||||
for (const hook of hooks) write(path.join(root, hook), '#!/bin/sh\nexit 0\n', 0o755);
|
||||
for (const rel of ['browse/dist/browse', 'browse/dist/server-node.mjs', 'browse/dist/.build-complete']) write(path.join(root, rel), 'built\n', 0o755);
|
||||
const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim();
|
||||
write(path.join(state, 'installs.tsv'),
|
||||
['claude', 'global', '-', path.join(home, '.claude/skills'), root, root, version, 'false', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n');
|
||||
script(path.join(stub, 'bun'), ['[ "$1" = --version ] && { echo "${STUB_BUN_VERSION:-1.4.2}"; exit 0; }', 'exit 0']);
|
||||
script(path.join(stub, 'codex'), ['[ "$1" = --version ] && { echo "codex-cli 0.130.0"; exit 0; }', 'echo "unexpected codex call: $*" >> "$DOCTOR_PROBE_LOG"', 'exit 9']);
|
||||
script(path.join(stub, 'claude'), ['[ "$1" = --version ] && { echo "2.1.292 (Claude Code)"; exit 0; }', 'exit 9']);
|
||||
const env: Record<string, string> = {
|
||||
PATH: [stub, hostPath(base)].join(path.delimiter),
|
||||
HOME: home, GSTACK_STATE_ROOT: state, TMPDIR: base, DOCTOR_PROBE_LOG: probeLog,
|
||||
};
|
||||
if (process.env.SYSTEMROOT) env.SYSTEMROOT = process.env.SYSTEMROOT;
|
||||
return { base, root, state, home, work, stub, probeLog, env };
|
||||
}
|
||||
|
||||
function doctor(f: Fixture, args: string[] = [], extra: Record<string, string> = {}) {
|
||||
const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-doctor'), ...args], { cwd: f.work, encoding: 'utf8', env: { ...f.env, ...extra }, timeout: 60_000 });
|
||||
const out = `${r.stdout}${r.stderr}`;
|
||||
return { status: r.status, out, row: (name: string) => rowOf(r.stdout, name) };
|
||||
}
|
||||
|
||||
/** The named row and its fix line: { state, detail, fix }. */
|
||||
function rowOf(stdout: string, name: string) {
|
||||
const lines = stdout.split('\n');
|
||||
const i = lines.findIndex(l => l.slice(16, 32).trim() === name);
|
||||
if (i < 0) throw new Error(`no row "${name}" in:\n${stdout}`);
|
||||
const fix = lines[i + 1]?.startsWith(' '.repeat(32) + 'fix: ') ? lines[i + 1].slice(37) : '';
|
||||
return { state: lines[i].slice(0, 16).trim(), detail: lines[i].slice(32), fix };
|
||||
}
|
||||
|
||||
/** How bash spells a directory (Git Bash on Windows prints /c/...). */
|
||||
const bashPath = (dir: string) => spawnSync('bash', ['-c', 'cd "$1" && pwd', '_', dir], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||
const probeCalls = (f: Fixture) => fs.existsSync(f.probeLog) ? fs.readFileSync(f.probeLog, 'utf8') : '';
|
||||
const iso = (secondsAgo: number) => new Date(Date.now() - secondsAgo * 1000).toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
const syncOn = (f: Fixture, status: Record<string, unknown>) => {
|
||||
fs.mkdirSync(path.join(f.state, '.git'), { recursive: true });
|
||||
write(path.join(f.state, 'config.yaml'), 'artifacts_sync_mode: artifacts-only\n');
|
||||
write(path.join(f.state, '.brain-sync-status.json'), JSON.stringify(status).replace(/":/g, '": ').replace(/,"/g, ', "') + '\n');
|
||||
};
|
||||
|
||||
describe('gstack-doctor', () => {
|
||||
test('a healthy fixture: every core row ok, exit 0, and no paid probe', () => {
|
||||
const f = makeFixture();
|
||||
const r = doctor(f);
|
||||
expect(r.status, r.out).toBe(0);
|
||||
for (const name of ['install root', 'state root', 'bun', 'hooks', 'browse bundle']) expect(r.row(name).state, `${name}\n${r.out}`).toBe('ok');
|
||||
expect(r.row('install root').detail).toContain('claude global current');
|
||||
expect(r.row('state root').detail).toContain('GSTACK_STATE_ROOT');
|
||||
expect(r.row('bun').detail).toContain('1.4.2');
|
||||
expect(r.row('codex').state).toBe('ok');
|
||||
expect(r.row('codex').detail).toContain('gpt-stub-5 (source: codex config.toml)');
|
||||
expect(r.row('codex probe').state).toBe('warn');
|
||||
expect(r.row('codex probe').detail).toContain('not probed');
|
||||
expect(r.row('codex probe').fix).toBe(`${bashPath(f.root)}/bin/gstack-doctor --live (one short paid Codex call)`);
|
||||
expect(r.row('artifacts sync').state).toBe('not configured');
|
||||
expect(r.row('claude code').detail).toContain('2.1.292');
|
||||
expect(r.row('guard log').detail).toBe('no guard decisions logged');
|
||||
expect(probeCalls(f)).not.toContain('probe-model');
|
||||
expect(probeCalls(f)).toContain('select-model exec');
|
||||
expect(r.out).toMatch(/\d+ ok, \d+ warn, \d+ not configured, 0 fail/);
|
||||
});
|
||||
|
||||
test('each DX-8 state appears, and only fail makes the exit non-zero', () => {
|
||||
const f = makeFixture();
|
||||
const warn = doctor(f, [], { STUB_BUN_VERSION: '1.4.0' });
|
||||
expect(warn.row('bun').state).toBe('warn');
|
||||
expect(warn.row('bun').fix).toContain('bun upgrade');
|
||||
expect(warn.status, warn.out).toBe(0);
|
||||
|
||||
write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n');
|
||||
const off = doctor(f);
|
||||
expect(off.row('codex').state).toBe('not configured');
|
||||
expect(off.row('codex').detail).toContain('outside reviews in /review, /ship, /autoplan and /codex');
|
||||
expect(off.row('codex').fix).toBe(`${bashPath(f.root)}/bin/gstack-config set codex_reviews enabled`);
|
||||
expect(off.status, off.out).toBe(0);
|
||||
expect(probeCalls(f)).not.toContain('probe-model');
|
||||
|
||||
const fail = doctor(f, [], { STUB_BUN_VERSION: '1.3.2' });
|
||||
expect(fail.row('bun').state).toBe('fail');
|
||||
expect(fail.row('bun').fix).toContain('bun upgrade');
|
||||
expect(fail.status, fail.out).toBe(1);
|
||||
expect(fail.out).toMatch(/, 1 fail/);
|
||||
});
|
||||
|
||||
test('Codex: absent CLI is not configured, unauthenticated warns, a failed self-locate fails', () => {
|
||||
const f = makeFixture();
|
||||
fs.rmSync(path.join(f.stub, 'codex'));
|
||||
const absent = doctor(f);
|
||||
expect(absent.row('codex').state).toBe('not configured');
|
||||
expect(absent.row('codex').detail).toContain('Codex CLI not installed');
|
||||
expect(absent.row('codex').fix).toContain('npm install -g @openai/codex');
|
||||
expect(absent.status).toBe(0);
|
||||
|
||||
const g = makeFixture();
|
||||
const unauthed = doctor(g, [], { STUB_AUTH_FAIL: '1' });
|
||||
expect(unauthed.row('codex').state).toBe('warn');
|
||||
expect(unauthed.row('codex').detail).toContain('not_authed');
|
||||
expect(unauthed.row('codex').fix).toContain('codex login');
|
||||
expect(unauthed.status).toBe(0);
|
||||
|
||||
const broken = doctor(g, [], { STUB_SELECT_FAIL: '1' });
|
||||
expect(broken.row('codex').state).toBe('fail');
|
||||
expect(broken.row('codex').detail).toContain('helper_unavailable');
|
||||
expect(broken.row('codex').fix).toContain(`${bashPath(g.root)}/bin/gstack-codex-probe select-model exec`);
|
||||
expect(broken.status).toBe(1);
|
||||
});
|
||||
|
||||
test('the cached probe is reported with its age; only --live runs probe-model', () => {
|
||||
const f = makeFixture();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
write(path.join(f.state, '.codex-model-probe'), `MODEL_OK ${now - 7300} 123456789\n`);
|
||||
const cached = doctor(f);
|
||||
expect(cached.row('codex probe').state).toBe('ok');
|
||||
expect(cached.row('codex probe').detail).toBe('cached MODEL_OK, 2h ago (not re-run; --live probes now)');
|
||||
write(path.join(f.state, '.codex-model-probe'), `MODEL_QUOTA_EXHAUSTED ${now - 125} 123456789\nCodex says: try again at 5pm sk-should-not-print\n`);
|
||||
const quota = doctor(f);
|
||||
expect(quota.row('codex probe').state).toBe('warn');
|
||||
expect(quota.row('codex probe').detail).toContain('cached MODEL_QUOTA_EXHAUSTED, 2m ago');
|
||||
expect(quota.out).not.toContain('sk-should-not-print');
|
||||
write(path.join(f.state, '.codex-model-probe'), `$(rm -rf ~) ${now} x\n`);
|
||||
expect(doctor(f).row('codex probe').detail).toContain('unrecognized');
|
||||
expect(probeCalls(f)).not.toContain('probe-model');
|
||||
|
||||
const live = doctor(f, ['--live']);
|
||||
expect(probeCalls(f).match(/probe-model exec/g)?.length).toBe(1);
|
||||
expect(live.row('codex probe').state).toBe('ok');
|
||||
expect(live.row('codex probe').detail).toContain('live probe: ok');
|
||||
const unusable = doctor(f, ['--live'], { STUB_PROBE_EXIT: '1', STUB_PROBE_STATE: 'model_unusable' });
|
||||
expect(unusable.row('codex probe').state).toBe('warn');
|
||||
expect(unusable.row('codex probe').detail).toContain('model_unusable');
|
||||
const brokenInstall = doctor(f, ['--live'], { STUB_PROBE_EXIT: '2', STUB_PROBE_STATE: 'broken_install' });
|
||||
expect(brokenInstall.row('codex probe').state).toBe('fail');
|
||||
expect(brokenInstall.status).toBe(1);
|
||||
});
|
||||
|
||||
test('a hook shim that does not parse fails the hooks row', () => {
|
||||
const f = makeFixture();
|
||||
write(path.join(f.root, 'autoplan/bin/phase-publication-hook'), '#!/bin/sh\nif then\n', 0o755);
|
||||
const r = doctor(f);
|
||||
expect(r.row('hooks').state).toBe('fail');
|
||||
expect(r.row('hooks').detail).toContain('autoplan/bin/phase-publication-hook');
|
||||
expect(r.row('hooks').fix).toContain('./setup');
|
||||
expect(r.status).toBe(1);
|
||||
});
|
||||
|
||||
test('artifacts sync: a hostile status clamps to unknown and is never echoed', () => {
|
||||
const f = makeFixture();
|
||||
syncOn(f, { status: 'AKIAIOSFODNN7EXAMPLE leaked', message: 'blocked: AKIAIOSFODNN7EXAMPLE in /secret/path.md', ts: iso(10) });
|
||||
const r = doctor(f);
|
||||
expect(r.row('artifacts sync').state).toBe('warn');
|
||||
expect(r.row('artifacts sync').detail).toBe('status unknown');
|
||||
expect(r.row('artifacts sync').fix).toBe(`${bashPath(f.root)}/bin/gstack-brain-sync --status`);
|
||||
expect(r.out).not.toContain('AKIA');
|
||||
expect(r.out).not.toContain('/secret/path.md');
|
||||
expect(r.out).not.toContain('.brain-sync-status.json');
|
||||
});
|
||||
|
||||
test('artifacts sync: held, ok, stale push and stale drain', () => {
|
||||
const f = makeFixture();
|
||||
syncOn(f, { status: 'held', message: 'held 2 file(s): sk-ant-secret', held: 2, drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
|
||||
const held = doctor(f);
|
||||
expect(held.row('artifacts sync').state).toBe('warn');
|
||||
expect(held.row('artifacts sync').detail).toBe('status held (2 file(s) held by the secret scanner)');
|
||||
expect(held.out).not.toContain('sk-ant');
|
||||
|
||||
syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
|
||||
expect(doctor(f).row('artifacts sync').state).toBe('ok');
|
||||
|
||||
syncOn(f, { status: 'ok', message: 'x', drainable: 4, last_drain_at: iso(60), last_push_at: iso(90_000) });
|
||||
const stalePush = doctor(f).row('artifacts sync');
|
||||
expect(stalePush.state).toBe('warn');
|
||||
expect(stalePush.detail).toContain('no push for 24h while 4 record(s) wait');
|
||||
|
||||
write(path.join(f.state, '.brain-queue.d/1-1-a.json'), '{}\n');
|
||||
syncOn(f, { status: 'idle', message: 'x', drainable: 0, last_drain_at: iso(200_000), last_push_at: iso(60) });
|
||||
const staleDrain = doctor(f).row('artifacts sync');
|
||||
expect(staleDrain.state).toBe('warn');
|
||||
expect(staleDrain.detail).toContain('no drain for 24h while 1 record(s) are queued');
|
||||
|
||||
syncOn(f, { status: 'ok', message: 'pushed' });
|
||||
expect(doctor(f).row('artifacts sync').state).toBe('ok');
|
||||
});
|
||||
|
||||
test('triage rows: Claude Code absent, largest journal, last five schema-1 guard codes', () => {
|
||||
const f = makeFixture();
|
||||
fs.rmSync(path.join(f.stub, 'claude'));
|
||||
expect(doctor(f).row('claude code').state).toBe('not configured');
|
||||
expect(doctor(f).row('session journal').detail).toBe('no session journals for this project');
|
||||
|
||||
const cwd = process.platform === 'win32'
|
||||
? spawnSync('bash', ['-c', 'pwd -W'], { cwd: f.work, encoding: 'utf8', timeout: 10_000 }).stdout.trim()
|
||||
: f.work;
|
||||
const project = path.join(f.home, '.claude/projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
|
||||
write(path.join(project, 'a.jsonl'), 'x'.repeat(1000));
|
||||
write(path.join(project, 'b.jsonl'), 'x'.repeat(3 * 1024 * 1024));
|
||||
const journal = doctor(f).row('session journal');
|
||||
expect(journal.state).toBe('ok');
|
||||
expect(journal.detail).toBe('largest of 2: 3.0 MiB');
|
||||
|
||||
const line = (schema: unknown, code: string | null, decision = 'deny') =>
|
||||
JSON.stringify({ schema, ts: '2026-10-07T00:00:00Z', decision, ...(code ? { reason_code: code } : {}), path: 'journal', cc_version: '2.1.292', disposition: 'hard' });
|
||||
write(path.join(f.state, 'analytics/autoplan-guard.jsonl'), [
|
||||
line(1, 'code_one'), line(2, 'future_schema'), line(1, 'code_two'), line(1, null, 'allow'), 'not json',
|
||||
line(1, 'code_three'), line(1, 'code_four'), line('1', 'string_schema'), line(1, 'code_five'), line(1, 'code_six'),
|
||||
].join('\n') + '\n');
|
||||
const guard = doctor(f).row('guard log');
|
||||
expect(guard.state).toBe('ok');
|
||||
expect(guard.detail).toBe('7 decisions (6 denied); last codes: code_two, code_three, code_four, code_five, code_six');
|
||||
});
|
||||
|
||||
test('every fix line names gstack helpers by absolute install-root path', () => {
|
||||
const f = makeFixture();
|
||||
write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n');
|
||||
fs.rmSync(path.join(f.root, 'browse/dist/browse'));
|
||||
const other = path.join(f.base, 'other');
|
||||
write(path.join(f.state, 'installs.tsv'), ['codex', 'global', '-', path.join(f.home, '.codex/skills'), other, other, '1', 'false', 'committed', 'x'].join('\t') + '\n');
|
||||
const r = doctor(f, [], { STUB_BUN_VERSION: '1.3.2' });
|
||||
const fixes = r.out.split('\n').filter(l => l.trimStart().startsWith('fix: '));
|
||||
expect(fixes.length).toBeGreaterThanOrEqual(5);
|
||||
expect(r.row('install root').fix).toContain(`${other}/bin/gstack-doctor`);
|
||||
const bin = `${bashPath(f.root)}/bin/`;
|
||||
for (const fix of fixes) {
|
||||
const scrubbed = fix.split(bin).join('<BIN>/').split(`${other}/bin/`).join('<OTHER>/').split(bashPath(f.base)).join('<BASE>');
|
||||
for (const m of scrubbed.matchAll(/gstack-[a-z]/g)) expect(/<(BIN|OTHER)>\/$/.test(scrubbed.slice(0, m.index)), fix).toBe(true);
|
||||
}
|
||||
expect(r.row('browse bundle').state).toBe('warn');
|
||||
expect(r.row('browse bundle').detail).toContain('/browse, /qa, /qa-only, /design-review, /make-pdf, /pair-agent');
|
||||
});
|
||||
|
||||
test('usage errors exit 2', () => {
|
||||
const f = makeFixture();
|
||||
expect(doctor(f, ['--bogus']).status).toBe(2);
|
||||
const help = doctor(f, ['--help']);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.out).toContain('--live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('./setup --status Codex row', () => {
|
||||
const status = (f: Fixture) => spawnSync('bash', [path.join(f.root, 'setup'), '--status'], { cwd: f.work, encoding: 'utf8', env: f.env, timeout: 60_000 });
|
||||
|
||||
test('reports install, version, codex_reviews and the cached probe, then ends with the doctor path', () => {
|
||||
const f = makeFixture();
|
||||
write(path.join(f.state, '.codex-model-probe'), `MODEL_UNUSABLE ${Math.floor(Date.now() / 1000) - 600} 1\n`);
|
||||
const r = status(f);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('Codex: installed (codex-cli 0.130.0); codex_reviews=enabled; model probe: MODEL_UNUSABLE 10m ago');
|
||||
expect(r.stdout.trimEnd().split('\n').at(-1)).toBe(`Readiness check: ${bashPath(f.root)}/bin/gstack-doctor`);
|
||||
expect(probeCalls(f)).toBe('');
|
||||
});
|
||||
|
||||
test('without Codex the row says so, and nothing is written', () => {
|
||||
const f = makeFixture();
|
||||
fs.rmSync(path.join(f.stub, 'codex'));
|
||||
write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n');
|
||||
const before = fs.readdirSync(f.state).sort();
|
||||
const r = status(f);
|
||||
expect(r.stdout).toContain('Codex: not installed (codex_reviews=disabled;');
|
||||
expect(fs.readdirSync(f.state).sort()).toEqual(before);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user