From fb9f75eb755e502f4ae7a7e7230963d99b2b3c9d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 15 Mar 2026 01:20:24 -0500 Subject: [PATCH] feat: smart update check with 12h cache, snooze backoff, config disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce cache TTL from 24h to 12h for faster update detection - Add exponential snooze backoff: 24h → 48h → 1 week (resets on new version) - Add update_check: false config option to disable checks entirely - Clear snooze file on just-upgraded - 14 new tests covering snooze levels, expiry, corruption, and config paths Co-Authored-By: Claude Opus 4.6 --- bin/gstack-update-check | 74 ++++++++++- browse/test/gstack-update-check.test.ts | 168 +++++++++++++++++++++++- 2 files changed, 236 insertions(+), 6 deletions(-) diff --git a/bin/gstack-update-check b/bin/gstack-update-check index c9fad0c2..7c5e5ca0 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# gstack-update-check — daily version check for all skills. +# gstack-update-check — periodic 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 +# (nothing) — up to date, snoozed, disabled, or check skipped # # Env overrides (for testing): # GSTACK_DIR — override auto-detected gstack root @@ -16,9 +16,65 @@ 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}" +# ─── 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 + +# ─── Snooze helper ────────────────────────────────────────── +# check_snooze +# Returns 0 if snoozed (should stay quiet), 1 if not snoozed (should output). +# +# Snooze file format: +# 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 @@ -32,6 +88,7 @@ fi if [ -f "$MARKER_FILE" ]; then OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')" rm -f "$MARKER_FILE" + rm -f "$SNOOZE_FILE" mkdir -p "$STATE_DIR" echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" if [ -n "$OLD" ]; then @@ -40,10 +97,10 @@ if [ -f "$MARKER_FILE" ]; then exit 0 fi -# ─── Step 3: Check cache freshness (24h = 1440 min) ────────── +# ─── Step 3: Check cache freshness (12h = 720 min) ────────── if [ -f "$CACHE_FILE" ]; then - # Cache is fresh if modified within 1440 minutes - STALE=$(find "$CACHE_FILE" -mmin +1440 2>/dev/null || true) + # 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)" @@ -60,6 +117,10 @@ if [ -f "$CACHE_FILE" ]; then # 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}')" + if check_snooze "$CACHED_NEW"; then + exit 0 # snoozed — stay quiet + fi echo "$CACHED" exit 0 fi @@ -90,4 +151,7 @@ fi # Versions differ — upgrade available echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE" +if check_snooze "$REMOTE"; then + exit 0 # snoozed — stay quiet +fi echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts index 475f3e64..2ec70e2d 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 } from 'fs'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -38,6 +38,10 @@ function run(extraEnv: Record = {}) { beforeEach(() => { gstackDir = mkdtempSync(join(tmpdir(), 'gstack-upd-test-')); stateDir = mkdtempSync(join(tmpdir(), 'gstack-state-test-')); + // Link real gstack-config so update_check config check works + const binDir = join(gstackDir, 'bin'); + mkdirSync(binDir); + symlinkSync(join(import.meta.dir, '..', '..', 'bin', 'gstack-config'), join(binDir, 'gstack-config')); }); afterEach(() => { @@ -45,6 +49,18 @@ afterEach(() => { rmSync(stateDir, { recursive: true, force: true }); }); +function writeSnooze(version: string, level: number, epochSeconds: number) { + writeFileSync(join(stateDir, 'update-snoozed'), `${version} ${level} ${epochSeconds}`); +} + +function writeConfig(content: string) { + writeFileSync(join(stateDir, 'config.yaml'), content); +} + +function nowEpoch(): number { + return Math.floor(Date.now() / 1000); +} + describe('gstack-update-check', () => { // ─── Path A: No VERSION file ──────────────────────────────── test('exits 0 with no output when VERSION file is missing', () => { @@ -246,4 +262,154 @@ describe('gstack-update-check', () => { expect(third.exitCode).toBe(0); expect(third.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); }); + + // ─── Snooze tests ─────────────────────────────────────────── + test('snoozed level 1 within 24h → silent (cached path)', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeSnooze('0.4.0', 1, nowEpoch() - 3600); // 1h ago (within 24h) + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('snoozed level 1 expired (25h ago) → outputs UPGRADE_AVAILABLE', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeSnooze('0.4.0', 1, nowEpoch() - 90000); // 25h ago + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('snoozed level 2 within 48h → silent', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeSnooze('0.4.0', 2, nowEpoch() - 86400); // 24h ago (within 48h) + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('snoozed level 2 expired (49h ago) → outputs', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeSnooze('0.4.0', 2, nowEpoch() - 176400); // 49h ago + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('snoozed level 3 within 7d → silent', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeSnooze('0.4.0', 3, nowEpoch() - 518400); // 6d ago (within 7d) + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('snoozed level 3 expired (8d ago) → outputs', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeSnooze('0.4.0', 3, nowEpoch() - 691200); // 8d ago + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('snooze ignored when version differs (new version resets snooze)', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.5.0'); + // Snoozed for 0.4.0, but remote is now 0.5.0 + writeSnooze('0.4.0', 3, nowEpoch() - 60); // very recent + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.5.0'); + }); + + test('corrupt snooze file → outputs normally', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeFileSync(join(stateDir, 'update-snoozed'), 'garbage'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('non-numeric epoch in snooze file → outputs', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeFileSync(join(stateDir, 'update-snoozed'), '0.4.0 1 abc'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('non-numeric level in snooze file → outputs', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0'); + writeFileSync(join(stateDir, 'update-snoozed'), `0.4.0 abc ${nowEpoch()}`); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('snooze respected on remote fetch path (no cache)', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + // No cache file — goes to remote fetch path + writeSnooze('0.4.0', 1, nowEpoch() - 3600); // 1h ago + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + // Cache should still be written + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); + + test('just-upgraded clears snooze file', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n'); + writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n'); + writeSnooze('0.4.0', 2, nowEpoch() - 3600); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('JUST_UPGRADED 0.3.3 0.4.0'); + expect(existsSync(join(stateDir, 'update-snoozed'))).toBe(false); + }); + + // ─── Config tests ────────────────────────────────────────── + test('update_check: false disables all checks', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + writeConfig('update_check: false\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + // No cache should be written + expect(existsSync(join(stateDir, 'last-update-check'))).toBe(false); + }); + + test('missing config.yaml does not crash', () => { + writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n'); + // No config file — should behave normally + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0'); + }); });