mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
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:
Executable
+88
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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})."
|
||||
Reference in New Issue
Block a user