diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 7c5e5ca0..d44c7e0f 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -20,6 +20,11 @@ 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 for standalone /gstack-upgrade) ── +if [ "${1:-}" = "--force" ]; then + rm -f "$CACHE_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 @@ -97,24 +102,27 @@ if [ -f "$MARKER_FILE" ]; then exit 0 fi -# ─── Step 3: Check cache freshness (12h = 720 min) ────────── +# ─── 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 - # Cache is fresh if modified within 720 minutes - STALE=$(find "$CACHE_FILE" -mmin +720 2>/dev/null || true) - if [ -z "$STALE" ]; then - # Cache is fresh — read it - CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)" + 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*) - # Verify local version still matches cached version CACHED_VER="$(echo "$CACHED" | awk '{print $2}')" if [ "$CACHED_VER" = "$LOCAL" ]; then exit 0 fi - # Local version changed — fall through to re-check ;; UPGRADE_AVAILABLE*) - # Verify local version still matches cached old version CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')" if [ "$CACHED_OLD" = "$LOCAL" ]; then CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')" @@ -124,7 +132,6 @@ if [ -f "$CACHE_FILE" ]; then echo "$CACHED" exit 0 fi - # Local version changed (manual upgrade?) — fall through to re-check ;; esac fi diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts index 2ec70e2d..66239931 100644 --- a/browse/test/gstack-update-check.test.ts +++ b/browse/test/gstack-update-check.test.ts @@ -7,7 +7,7 @@ */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync, mkdirSync, symlinkSync, utimesSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -16,8 +16,8 @@ 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], { +function run(extraEnv: Record = {}, args: string[] = []) { + const result = Bun.spawnSync(['bash', SCRIPT, ...args], { env: { ...process.env, GSTACK_DIR: gstackDir, @@ -412,4 +412,56 @@ describe('gstack-update-check', () => { expect(exitCode).toBe(0); expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); }); + + // ─── --force flag tests ────────────────────────────────────── + + test('--force busts fresh UP_TO_DATE cache', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3'); + + // Without --force: cache hit, silent + const cached = run(); + expect(cached.stdout).toBe(''); + + // With --force: cache busted, re-fetches, finds upgrade + const forced = run({}, ['--force']); + expect(forced.exitCode).toBe(0); + expect(forced.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('--force busts fresh UPGRADE_AVAILABLE cache', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + + // Without --force: cache hit, outputs stale upgrade + const cached = run(); + expect(cached.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + + // With --force: cache busted, re-fetches, now up to date + const forced = run({}, ['--force']); + expect(forced.exitCode).toBe(0); + expect(forced.stdout).toBe(''); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE'); + }); + + // ─── Split TTL tests ───────────────────────────────────────── + + test('UP_TO_DATE cache expires after 60 min (not 720)', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3'); + + // Set cache mtime to 90 minutes ago (past 60-min TTL) + const ninetyMinAgo = new Date(Date.now() - 90 * 60 * 1000); + const cachePath = join(stateDir, 'last-update-check'); + utimesSync(cachePath, ninetyMinAgo, ninetyMinAgo); + + // Cache should be stale at 60-min TTL, re-fetches and finds upgrade + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); });