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.
This commit is contained in:
Garry Tan
2026-03-13 23:57:05 -05:00
parent 5205070299
commit ff55e532f7
3 changed files with 373 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# gstack-update-check — daily 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 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"
+173
View File
@@ -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<string, string> = {}) {
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'), '<html>404 Not Found</html>\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);
});
});
+112
View File
@@ -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})."