From ff55e532f7630957102a4171811a670eb277188f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 13 Mar 2026 23:57:05 -0500 Subject: [PATCH] feat: add daily update check script + /gstack-upgrade skill bin/gstack-update-check: pure bash, checks VERSION against remote once/day, outputs UPGRADE_AVAILABLE or JUST_UPGRADED. Uses ~/.gstack/ for state. gstack-upgrade/SKILL.md: new skill with inline upgrade flow for all preambles. Detects global-git, local-git, vendored installs. Shows What's New from CHANGELOG. browse/test/gstack-update-check.test.ts: 10 test cases covering all branch paths. --- bin/gstack-update-check | 88 ++++++++++++ browse/test/gstack-update-check.test.ts | 173 ++++++++++++++++++++++++ gstack-upgrade/SKILL.md | 112 +++++++++++++++ 3 files changed, 373 insertions(+) create mode 100755 bin/gstack-update-check create mode 100644 browse/test/gstack-update-check.test.ts create mode 100644 gstack-upgrade/SKILL.md diff --git a/bin/gstack-update-check b/bin/gstack-update-check new file mode 100755 index 00000000..79986ba1 --- /dev/null +++ b/bin/gstack-update-check @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# gstack-update-check — daily version check for all skills. +# +# Output (one line, or nothing): +# JUST_UPGRADED — marker found from recent upgrade +# UPGRADE_AVAILABLE — remote VERSION differs from local +# (nothing) — up to date 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" +VERSION_FILE="$GSTACK_DIR/VERSION" +REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}" + +# ─── 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" + mkdir -p "$STATE_DIR" + echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" + if [ -n "$OLD" ]; then + echo "JUST_UPGRADED $OLD $LOCAL" + fi + exit 0 +fi + +# ─── Step 3: Check cache freshness (24h = 1440 min) ────────── +if [ -f "$CACHE_FILE" ]; then + # Cache is fresh if modified within 1440 minutes + STALE=$(find "$CACHE_FILE" -mmin +1440 2>/dev/null || true) + if [ -z "$STALE" ]; then + # Cache is fresh — read it + CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)" + case "$CACHED" in + UP_TO_DATE*) + exit 0 + ;; + UPGRADE_AVAILABLE*) + # Verify local version still matches cached old version + CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')" + if [ "$CACHED_OLD" = "$LOCAL" ]; then + echo "$CACHED" + exit 0 + fi + # Local version changed (manual upgrade?) — fall through to re-check + ;; + esac + fi +fi + +# ─── Step 4: Slow path — fetch remote version ──────────────── +mkdir -p "$STATE_DIR" + +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" +echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts new file mode 100644 index 00000000..7856f03d --- /dev/null +++ b/browse/test/gstack-update-check.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for bin/gstack-update-check bash script. + * + * Uses Bun.spawnSync to invoke the script with temp dirs and + * GSTACK_DIR / GSTACK_STATE_DIR / GSTACK_REMOTE_URL env overrides + * for full isolation. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-update-check'); + +let gstackDir: string; +let stateDir: string; + +function run(extraEnv: Record = {}) { + const result = Bun.spawnSync(['bash', SCRIPT], { + env: { + ...process.env, + GSTACK_DIR: gstackDir, + GSTACK_STATE_DIR: stateDir, + GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`, + ...extraEnv, + }, + stdout: 'pipe', + stderr: 'pipe', + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + }; +} + +beforeEach(() => { + gstackDir = mkdtempSync(join(tmpdir(), 'gstack-upd-test-')); + stateDir = mkdtempSync(join(tmpdir(), 'gstack-state-test-')); +}); + +afterEach(() => { + rmSync(gstackDir, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe('gstack-update-check', () => { + // ─── Path A: No VERSION file ──────────────────────────────── + test('exits 0 with no output when VERSION file is missing', () => { + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + // ─── Path B: Empty VERSION file ───────────────────────────── + test('exits 0 with no output when VERSION file is empty', () => { + writeFileSync(join(gstackDir, 'VERSION'), ''); + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + // ─── Path C: Just-upgraded marker ─────────────────────────── + test('outputs JUST_UPGRADED and deletes marker', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n'); + writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('JUST_UPGRADED 0.3.3 0.4.0'); + // Marker should be deleted + expect(existsSync(join(stateDir, 'just-upgraded-from'))).toBe(false); + // Cache should be written + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE'); + }); + + // ─── Path D1: Fresh cache, UP_TO_DATE ─────────────────────── + test('exits silently when cache says UP_TO_DATE and is fresh', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + // ─── Path D2: Fresh cache, UPGRADE_AVAILABLE ──────────────── + test('echoes cached UPGRADE_AVAILABLE when cache is fresh', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + // ─── Path D3: Fresh cache, but local version changed ──────── + test('re-checks when local version does not match cached old version', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n'); + // Cache says 0.3.3 → 0.4.0 but we're already on 0.4.0 + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + // Remote also says 0.4.0 — should be up to date + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); // Up to date after re-check + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE'); + }); + + // ─── Path E: Versions match (remote fetch) ───────────────── + test('writes UP_TO_DATE cache when versions match', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE'); + }); + + // ─── Path F: Versions differ (remote fetch) ───────────────── + test('outputs UPGRADE_AVAILABLE when versions differ', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + // ─── Path G: Invalid remote response ──────────────────────── + test('treats invalid remote response as up to date', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '404 Not Found\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE'); + }); + + // ─── Path H: Curl fails (bad URL) ────────────────────────── + test('exits silently when remote URL is unreachable', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + + const { exitCode, stdout } = run({ + GSTACK_REMOTE_URL: 'file:///nonexistent/path/VERSION', + }); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE'); + }); + + // ─── State dir creation ───────────────────────────────────── + test('creates state dir if it does not exist', () => { + const newStateDir = join(stateDir, 'nested', 'dir'); + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n'); + + const { exitCode } = run({ GSTACK_STATE_DIR: newStateDir }); + expect(exitCode).toBe(0); + expect(existsSync(join(newStateDir, 'last-update-check'))).toBe(true); + }); +}); diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md new file mode 100644 index 00000000..a945de17 --- /dev/null +++ b/gstack-upgrade/SKILL.md @@ -0,0 +1,112 @@ +--- +name: gstack-upgrade +version: 1.0.0 +description: | + Upgrade gstack to the latest version. Detects global vs vendored install, + runs the upgrade, and shows what's new. +allowed-tools: + - Bash + - Read + - AskUserQuestion +--- + +# /gstack-upgrade + +Upgrade gstack to the latest version and show what's new. + +## Inline upgrade flow + +This section is referenced by all skill preambles when they detect `UPGRADE_AVAILABLE`. + +### Step 1: Ask the user + +Use AskUserQuestion: +- Question: "gstack **v{new}** is available (you're on v{old}). Upgrade now? Takes ~10 seconds." +- Options: ["Yes, upgrade now", "Later (ask again tomorrow)"] + +**If "Later":** Run `touch ~/.gstack/last-update-check` to reset the 24h timer and continue with the current skill. Do not mention the upgrade again. + +### Step 2: Detect install type + +```bash +if [ -d "$HOME/.claude/skills/gstack/.git" ]; then + INSTALL_TYPE="global-git" + INSTALL_DIR="$HOME/.claude/skills/gstack" +elif [ -d ".claude/skills/gstack/.git" ]; then + INSTALL_TYPE="local-git" + INSTALL_DIR=".claude/skills/gstack" +elif [ -d ".claude/skills/gstack" ]; then + INSTALL_TYPE="vendored" + INSTALL_DIR=".claude/skills/gstack" +elif [ -d "$HOME/.claude/skills/gstack" ]; then + INSTALL_TYPE="vendored-global" + INSTALL_DIR="$HOME/.claude/skills/gstack" +else + echo "ERROR: gstack not found" + exit 1 +fi +echo "Install type: $INSTALL_TYPE at $INSTALL_DIR" +``` + +### Step 3: Save old version + +```bash +OLD_VERSION=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo "unknown") +``` + +### Step 4: Upgrade + +**For git installs** (global-git, local-git): +```bash +cd "$INSTALL_DIR" +STASH_OUTPUT=$(git stash 2>&1) +git fetch origin +git reset --hard origin/main +./setup +``` +If `$STASH_OUTPUT` contains "Saved working directory", warn the user: "Note: local changes were stashed. Run `git stash pop` in the skill directory to restore them." + +**For vendored installs** (vendored, vendored-global): +```bash +PARENT=$(dirname "$INSTALL_DIR") +TMP_DIR=$(mktemp -d) +git clone --depth 1 https://github.com/garrytan/gstack.git "$TMP_DIR/gstack" +mv "$INSTALL_DIR" "$INSTALL_DIR.bak" +mv "$TMP_DIR/gstack" "$INSTALL_DIR" +cd "$INSTALL_DIR" && ./setup +rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" +``` + +### Step 5: Write marker + clear cache + +```bash +mkdir -p ~/.gstack +echo "$OLD_VERSION" > ~/.gstack/just-upgraded-from +rm -f ~/.gstack/last-update-check +``` + +### Step 6: Show What's New + +Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant. + +Format: +``` +gstack v{new} — upgraded from v{old}! + +What's new: +- [bullet 1] +- [bullet 2] +- ... + +Happy shipping! +``` + +### Step 7: Continue + +After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed. + +--- + +## Standalone usage + +When invoked directly as `/gstack-upgrade` (not from a preamble), follow Steps 2-6 above. If already on the latest version, tell the user: "You're already on the latest version (v{version})."