#!/usr/bin/env bash
# gstack-update-check — periodic version check for all skills.
#
# Output (one line, or nothing):
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
# (nothing) — up to date, snoozed, disabled, or check skipped
#
# Env overrides (for testing):
# GSTACK_DIR — override auto-detected gstack root
# GSTACK_REMOTE_URL — override remote VERSION URL
# GSTACK_STATE_DIR — override ~/.gstack state directory
set -euo pipefail
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
CACHE_FILE="$STATE_DIR/last-update-check"
MARKER_FILE="$STATE_DIR/just-upgraded-from"
SNOOZE_FILE="$STATE_DIR/update-snoozed"
VERSION_FILE="$GSTACK_DIR/VERSION"
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
if [ "${1:-}" = "--force" ]; then
rm -f "$CACHE_FILE"
rm -f "$SNOOZE_FILE"
fi
# ─── Step 0: Check if updates are disabled ────────────────────
_UC=$("$GSTACK_DIR/bin/gstack-config" get update_check 2>/dev/null || true)
if [ "$_UC" = "false" ]; then
exit 0
fi
# ─── Migration: fix stale Codex descriptions (one-time) ───────
# Existing installs may have .agents/skills/gstack/SKILL.md with oversized
# descriptions (>1024 chars) that Codex rejects. We can't regenerate from
# the runtime root (no bun/scripts), so delete oversized files — the next
# ./setup or /gstack-upgrade will regenerate them properly.
# Marker file ensures this runs at most once per install.
if [ ! -f "$STATE_DIR/.codex-desc-healed" ]; then
for _AGENTS_SKILL in "$GSTACK_DIR"/.agents/skills/*/SKILL.md; do
[ -f "$_AGENTS_SKILL" ] || continue
_DESC=$(awk '/^---$/{n++;next}n==1&&/^description:/{d=1;sub(/^description:\s*/,"");if(length>0)print;next}d&&/^ /{sub(/^ /,"");print;next}d{d=0}' "$_AGENTS_SKILL" | wc -c | tr -d ' ')
if [ "${_DESC:-0}" -gt 1024 ]; then
rm -f "$_AGENTS_SKILL"
fi
done
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/.codex-desc-healed"
fi
# ─── Snooze helper ──────────────────────────────────────────
# check_snooze <remote_version>
# Returns 0 if snoozed (should stay quiet), 1 if not snoozed (should output).
#
# Snooze file format: <version> <level> <epoch>
# Level durations: 1=24h, 2=48h, 3+=7d
# New version (version mismatch) resets snooze.
check_snooze() {
local remote_ver="$1"
if [ ! -f "$SNOOZE_FILE" ]; then
return 1 # no snooze file → not snoozed
fi
local snoozed_ver snoozed_level snoozed_epoch
snoozed_ver="$(awk '{print $1}' "$SNOOZE_FILE" 2>/dev/null || true)"
snoozed_level="$(awk '{print $2}' "$SNOOZE_FILE" 2>/dev/null || true)"
snoozed_epoch="$(awk '{print $3}' "$SNOOZE_FILE" 2>/dev/null || true)"
# Validate: all three fields must be non-empty
if [ -z "$snoozed_ver" ] || [ -z "$snoozed_level" ] || [ -z "$snoozed_epoch" ]; then
return 1 # corrupt file → not snoozed
fi
# Validate: level and epoch must be integers
case "$snoozed_level" in *[!0-9]*) return 1 ;; esac
case "$snoozed_epoch" in *[!0-9]*) return 1 ;; esac
# New version dropped? Ignore snooze.
if [ "$snoozed_ver" != "$remote_ver" ]; then
return 1
fi
# Compute snooze duration based on level
local duration
case "$snoozed_level" in
1) duration=86400 ;; # 24 hours
2) duration=172800 ;; # 48 hours
*) duration=604800 ;; # 7 days (level 3+)
esac
local now
now="$(date +%s)"
local expires=$(( snoozed_epoch + duration ))
if [ "$now" -lt "$expires" ]; then
return 0 # still snoozed
fi
return 1 # snooze expired
}
# ─── Step 1: Read local version ──────────────────────────────
LOCAL=""
if [ -f "$VERSION_FILE" ]; then
LOCAL="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')"
fi
if [ -z "$LOCAL" ]; then
exit 0 # No VERSION file → skip check
fi
# ─── Step 2: Check "just upgraded" marker ─────────────────────
if [ -f "$MARKER_FILE" ]; then
OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')"
rm -f "$MARKER_FILE"
rm -f "$SNOOZE_FILE"
if [ -n "$OLD" ]; then
echo "JUST_UPGRADED $OLD $LOCAL"
fi
# Don't exit — fall through to remote check in case
# more updates landed since the upgrade
fi
# ─── Step 3: Check cache freshness ──────────────────────────
# UP_TO_DATE: 60 min TTL (detect new releases quickly)
# UPGRADE_AVAILABLE: 720 min TTL (keep nagging)
if [ -f "$CACHE_FILE" ]; then
CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)"
case "$CACHED" in
UP_TO_DATE*) CACHE_TTL=60 ;;
UPGRADE_AVAILABLE*) CACHE_TTL=720 ;;
*) CACHE_TTL=0 ;; # corrupt → force re-fetch
esac
STALE=$(find "$CACHE_FILE" -mmin +$CACHE_TTL 2>/dev/null || true)
if [ -z "$STALE" ] && [ "$CACHE_TTL" -gt 0 ]; then
case "$CACHED" in
UP_TO_DATE*)
CACHED_VER="$(echo "$CACHED" | awk '{print $2}')"
if [ "$CACHED_VER" = "$LOCAL" ]; then
exit 0
fi
;;
UPGRADE_AVAILABLE*)
CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')"
if [ "$CACHED_OLD" = "$LOCAL" ]; then
CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')"
if check_snooze "$CACHED_NEW"; then
exit 0 # snoozed — stay quiet
fi
echo "$CACHED"
exit 0
fi
;;
esac
fi
fi
# ─── Step 4: Slow path — fetch remote version ────────────────
mkdir -p "$STATE_DIR"
# Fire Supabase install ping in background (parallel, non-blocking)
# This logs an update check event for community health metrics via edge function.
# If Supabase is not configured or telemetry is off, this is a no-op.
if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
. "$GSTACK_DIR/supabase/config.sh"
fi
_SUPA_URL="${GSTACK_SUPABASE_URL:-}"
_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}"
# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off
_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)"
if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then
_OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
curl -sf --max-time 5 \
-X POST "${_SUPA_URL}/functions/v1/update-check" \
-H "Content-Type: application/json" \
-H "apikey: ${_SUPA_KEY}" \
-d "{\"version\":\"$LOCAL\",\"os\":\"$_OS\"}" \
>/dev/null 2>&1 &
fi
# GitHub raw fetch (primary, always reliable)
REMOTE=""
REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')"
# Validate: must look like a version number (reject HTML error pages)
if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then
# Invalid or empty response — assume up to date
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
exit 0
fi
if [ "$LOCAL" = "$REMOTE" ]; then
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
exit 0
fi
# Versions differ — upgrade available
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
if check_snooze "$REMOTE"; then
exit 0 # snoozed — stay quiet
fi
# Log upgrade_prompted event (only on slow-path fetch, not cached replays)
TEL_CMD="$GSTACK_DIR/bin/gstack-telemetry-log"
if [ -x "$TEL_CMD" ]; then
"$TEL_CMD" --event-type upgrade_prompted --skill "" --duration 0 \
--outcome success --session-id "update-$$-$(date +%s)" 2>/dev/null &
fi
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE"