diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e02e78..4c571e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.3.9 — 2026-03-15 + +### Added +- **`bin/gstack-config` CLI** — simple get/set/list interface for `~/.gstack/config.yaml`. Used by update-check and upgrade skill for persistent settings (auto_upgrade, update_check). +- **Smart update check** — 12h cache TTL (was 24h), exponential snooze backoff (24h → 48h → 1 week) when user declines upgrades, `update_check: false` config option to disable checks entirely. Snooze resets when a new version is released. +- **Auto-upgrade mode** — set `auto_upgrade: true` in config or `GSTACK_AUTO_UPGRADE=1` env var to skip the upgrade prompt and update automatically. +- **4-option upgrade prompt** — "Yes, upgrade now", "Always keep me up to date", "Not now" (snooze), "Never ask again" (disable). +- **Vendored copy sync** — `/gstack-upgrade` now detects and updates local vendored copies in the current project after upgrading the primary install. +- 25 new tests: 11 for gstack-config CLI, 14 for snooze/config paths in update-check. + +### Changed +- README upgrade/troubleshooting sections simplified to reference `/gstack-upgrade` instead of long paste commands. +- Upgrade skill template bumped to v1.1.0 with `Write` tool permission for config editing. +- All SKILL.md preambles updated with new upgrade flow description. + ## 0.3.8 — 2026-03-14 ### Added diff --git a/README.md b/README.md index 3e32fa89..27548066 100644 --- a/README.md +++ b/README.md @@ -598,18 +598,16 @@ Run `cd ~/.claude/skills/gstack && ./setup` (or `cd .claude/skills/gstack && ./s Run `cd ~/.claude/skills/gstack && bun install && bun run build`. This compiles the browser binary. Requires Bun v1.0+. **Project copy is stale?** -Re-copy from global: `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` +Run `/gstack-upgrade` — it updates both the global install and any vendored project copy automatically. **`bun` not installed?** Install it: `curl -fsSL https://bun.sh/install | bash` ## Upgrading -Paste this into Claude Code: +Run `/gstack-upgrade` in Claude Code. It detects your install type (global or vendored), upgrades, syncs any project copies, and shows what's new. -> Update gstack: run `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main && ./setup`. If this project also has gstack at .claude/skills/gstack, update it too: run `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` - -The `setup` script rebuilds the browser binary and re-symlinks skills. It takes a few seconds. +Or set `auto_upgrade: true` in `~/.gstack/config.yaml` to upgrade automatically whenever a new version is available. ## Uninstalling diff --git a/SKILL.md b/SKILL.md index c90218c6..9cc9acdc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # gstack browse: QA Testing & Dogfooding diff --git a/TODOS.md b/TODOS.md index e98fcc1c..4916c236 100644 --- a/TODOS.md +++ b/TODOS.md @@ -338,17 +338,6 @@ **Effort:** XS **Priority:** P2 -### Auto-upgrade mode (zero-prompt) - -**What:** `GSTACK_AUTO_UPGRADE=1` env var or `~/.gstack/config` option that skips the AskUserQuestion prompt and upgrades automatically. - -**Why:** Power users and CI environments want zero-friction upgrades. - -**Context:** Current upgrade system (v0.3.4) always prompts. This adds opt-in bypass. ~10 lines in preamble instructions. - -**Effort:** S -**Priority:** P3 - ### Eval web dashboard **What:** `bun run eval:dashboard` serves local HTML with charts: cost trending, detection rate, pass/fail history. @@ -395,3 +384,7 @@ ### E2E test cost tracking - Track cumulative API spend, warn if over threshold **Completed:** v0.3.6 + +### Auto-upgrade mode + smart update check +- Config CLI (`bin/gstack-config`), auto-upgrade via `~/.gstack/config.yaml`, 12h cache TTL, exponential snooze backoff (24h→48h→1wk), "never ask again" option, vendored copy sync on upgrade +**Completed:** v0.3.8 diff --git a/VERSION b/VERSION index 66784322..940ac09a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.8 +0.3.9 diff --git a/bin/gstack-config b/bin/gstack-config new file mode 100755 index 00000000..e99a940b --- /dev/null +++ b/bin/gstack-config @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# gstack-config — read/write ~/.gstack/config.yaml +# +# Usage: +# gstack-config get — read a config value +# gstack-config set — write a config value +# gstack-config list — show all config +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -euo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +CONFIG_FILE="$STATE_DIR/config.yaml" + +case "${1:-}" in + get) + KEY="${2:?Usage: gstack-config get }" + grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true + ;; + set) + KEY="${2:?Usage: gstack-config set }" + VALUE="${3:?Usage: gstack-config set }" + mkdir -p "$STATE_DIR" + if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then + sed -i '' "s/^${KEY}:.*/${KEY}: ${VALUE}/" "$CONFIG_FILE" + else + echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" + fi + ;; + list) + cat "$CONFIG_FILE" 2>/dev/null || true + ;; + *) + echo "Usage: gstack-config {get|set|list} [key] [value]" + exit 1 + ;; +esac 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/SKILL.md b/browse/SKILL.md index e383e906..22c5d888 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # browse: QA Testing & Dogfooding diff --git a/browse/test/gstack-config.test.ts b/browse/test/gstack-config.test.ts new file mode 100644 index 00000000..8a7b6dea --- /dev/null +++ b/browse/test/gstack-config.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for bin/gstack-config bash script. + * + * Uses Bun.spawnSync to invoke the script with temp dirs and + * GSTACK_STATE_DIR env override for full isolation. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config'); + +let stateDir: string; + +function run(args: string[] = [], extraEnv: Record = {}) { + const result = Bun.spawnSync(['bash', SCRIPT, ...args], { + env: { + ...process.env, + GSTACK_STATE_DIR: stateDir, + ...extraEnv, + }, + stdout: 'pipe', + stderr: 'pipe', + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + }; +} + +beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), 'gstack-config-test-')); +}); + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe('gstack-config', () => { + // ─── get ────────────────────────────────────────────────── + test('get on missing file returns empty, exit 0', () => { + const { exitCode, stdout } = run(['get', 'auto_upgrade']); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('get existing key returns value', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n'); + const { exitCode, stdout } = run(['get', 'auto_upgrade']); + expect(exitCode).toBe(0); + expect(stdout).toBe('true'); + }); + + test('get missing key returns empty', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n'); + const { exitCode, stdout } = run(['get', 'nonexistent']); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('get returns last value when key appears multiple times', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\nfoo: baz\n'); + const { exitCode, stdout } = run(['get', 'foo']); + expect(exitCode).toBe(0); + expect(stdout).toBe('baz'); + }); + + // ─── set ────────────────────────────────────────────────── + test('set creates file and writes key on missing file', () => { + const { exitCode } = run(['set', 'auto_upgrade', 'true']); + expect(exitCode).toBe(0); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('auto_upgrade: true'); + }); + + test('set appends new key to existing file', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\n'); + const { exitCode } = run(['set', 'auto_upgrade', 'true']); + expect(exitCode).toBe(0); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('foo: bar'); + expect(content).toContain('auto_upgrade: true'); + }); + + test('set replaces existing key in-place', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: false\n'); + const { exitCode } = run(['set', 'auto_upgrade', 'true']); + expect(exitCode).toBe(0); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('auto_upgrade: true'); + expect(content).not.toContain('auto_upgrade: false'); + }); + + test('set creates state dir if missing', () => { + const nestedDir = join(stateDir, 'nested', 'dir'); + const { exitCode } = run(['set', 'foo', 'bar'], { GSTACK_STATE_DIR: nestedDir }); + expect(exitCode).toBe(0); + expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true); + }); + + // ─── list ───────────────────────────────────────────────── + test('list shows all keys', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n'); + const { exitCode, stdout } = run(['list']); + expect(exitCode).toBe(0); + expect(stdout).toContain('auto_upgrade: true'); + expect(stdout).toContain('update_check: false'); + }); + + test('list on missing file returns empty, exit 0', () => { + const { exitCode, stdout } = run(['list']); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + // ─── usage ──────────────────────────────────────────────── + test('no args shows usage and exits 1', () => { + const { exitCode, stdout } = run([]); + expect(exitCode).toBe(1); + expect(stdout).toContain('Usage'); + }); +}); 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'); + }); }); diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 6c5edaed..1cf7d548 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -1,12 +1,13 @@ --- name: gstack-upgrade -version: 1.0.0 +version: 1.1.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 + - Write - AskUserQuestion --- @@ -20,13 +21,56 @@ Upgrade gstack to the latest version and show what's new. This section is referenced by all skill preambles when they detect `UPGRADE_AVAILABLE`. -### Step 1: Ask the user +### Step 1: Ask the user (or auto-upgrade) -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)"] +First, check if auto-upgrade is enabled: +```bash +_AUTO="" +[ "${GSTACK_AUTO_UPGRADE:-}" = "1" ] && _AUTO="true" +[ -z "$_AUTO" ] && _AUTO=$(~/.claude/skills/gstack/bin/gstack-config get auto_upgrade 2>/dev/null || true) +echo "AUTO_UPGRADE=$_AUTO" +``` -**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. +**If `AUTO_UPGRADE=true` or `AUTO_UPGRADE=1`:** Skip AskUserQuestion. Log "Auto-upgrading gstack v{old} → v{new}..." and proceed directly to Step 2. If `./setup` fails during auto-upgrade, restore from backup (`.bak` directory) and warn the user: "Auto-upgrade failed — restored previous version. Run `/gstack-upgrade` manually to retry." + +**Otherwise**, use AskUserQuestion: +- Question: "gstack **v{new}** is available (you're on v{old}). Upgrade now?" +- Options: ["Yes, upgrade now", "Always keep me up to date", "Not now", "Never ask again"] + +**If "Yes, upgrade now":** Proceed to Step 2. + +**If "Always keep me up to date":** +```bash +~/.claude/skills/gstack/bin/gstack-config set auto_upgrade true +``` +Tell user: "Auto-upgrade enabled. Future updates will install automatically." Then proceed to Step 2. + +**If "Not now":** Write snooze state with escalating backoff (first snooze = 24h, second = 48h, third+ = 1 week), then continue with the current skill. Do not mention the upgrade again. +```bash +_SNOOZE_FILE=~/.gstack/update-snoozed +_REMOTE_VER="{new}" +_CUR_LEVEL=0 +if [ -f "$_SNOOZE_FILE" ]; then + _SNOOZED_VER=$(awk '{print $1}' "$_SNOOZE_FILE") + if [ "$_SNOOZED_VER" = "$_REMOTE_VER" ]; then + _CUR_LEVEL=$(awk '{print $2}' "$_SNOOZE_FILE") + case "$_CUR_LEVEL" in *[!0-9]*) _CUR_LEVEL=0 ;; esac + fi +fi +_NEW_LEVEL=$((_CUR_LEVEL + 1)) +[ "$_NEW_LEVEL" -gt 3 ] && _NEW_LEVEL=3 +echo "$_REMOTE_VER $_NEW_LEVEL $(date +%s)" > "$_SNOOZE_FILE" +``` +Note: `{new}` is the remote version from the `UPGRADE_AVAILABLE` output — substitute it from the update check result. + +Tell user the snooze duration: "Next reminder in 24h" (or 48h or 1 week, depending on level). Tip: "Set `auto_upgrade: true` in `~/.gstack/config.yaml` for automatic upgrades." + +**If "Never ask again":** +```bash +~/.claude/skills/gstack/bin/gstack-config set update_check false +``` +Tell user: "Update checks disabled. Run `~/.claude/skills/gstack/bin/gstack-config set update_check true` to re-enable." +Continue with the current skill. ### Step 2: Detect install type @@ -79,12 +123,40 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` +### Step 4.5: Sync local vendored copy + +After upgrading the primary install, check if there's also a local copy in the current project that needs updating: + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +LOCAL_GSTACK="" +if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then + _RESOLVED_LOCAL=$(cd "$_ROOT/.claude/skills/gstack" && pwd -P) + _RESOLVED_PRIMARY=$(cd "$INSTALL_DIR" && pwd -P) + if [ "$_RESOLVED_LOCAL" != "$_RESOLVED_PRIMARY" ]; then + LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" + fi +fi +echo "LOCAL_GSTACK=$LOCAL_GSTACK" +``` + +If `LOCAL_GSTACK` is non-empty, update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +```bash +mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" +cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" +rm -rf "$LOCAL_GSTACK/.git" +cd "$LOCAL_GSTACK" && ./setup +rm -rf "$LOCAL_GSTACK.bak" +``` +Tell user: "Also updated vendored copy at `$LOCAL_GSTACK` — commit `.claude/skills/gstack/` when you're ready." + ### Step 5: Write marker + clear cache ```bash mkdir -p ~/.gstack echo "$OLD_VERSION" > ~/.gstack/just-upgraded-from rm -f ~/.gstack/last-update-check +rm -f ~/.gstack/update-snoozed ``` ### Step 6: Show What's New diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index a945de17..4a124be1 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -1,12 +1,13 @@ --- name: gstack-upgrade -version: 1.0.0 +version: 1.1.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 + - Write - AskUserQuestion --- @@ -18,13 +19,56 @@ Upgrade gstack to the latest version and show what's new. This section is referenced by all skill preambles when they detect `UPGRADE_AVAILABLE`. -### Step 1: Ask the user +### Step 1: Ask the user (or auto-upgrade) -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)"] +First, check if auto-upgrade is enabled: +```bash +_AUTO="" +[ "${GSTACK_AUTO_UPGRADE:-}" = "1" ] && _AUTO="true" +[ -z "$_AUTO" ] && _AUTO=$(~/.claude/skills/gstack/bin/gstack-config get auto_upgrade 2>/dev/null || true) +echo "AUTO_UPGRADE=$_AUTO" +``` -**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. +**If `AUTO_UPGRADE=true` or `AUTO_UPGRADE=1`:** Skip AskUserQuestion. Log "Auto-upgrading gstack v{old} → v{new}..." and proceed directly to Step 2. If `./setup` fails during auto-upgrade, restore from backup (`.bak` directory) and warn the user: "Auto-upgrade failed — restored previous version. Run `/gstack-upgrade` manually to retry." + +**Otherwise**, use AskUserQuestion: +- Question: "gstack **v{new}** is available (you're on v{old}). Upgrade now?" +- Options: ["Yes, upgrade now", "Always keep me up to date", "Not now", "Never ask again"] + +**If "Yes, upgrade now":** Proceed to Step 2. + +**If "Always keep me up to date":** +```bash +~/.claude/skills/gstack/bin/gstack-config set auto_upgrade true +``` +Tell user: "Auto-upgrade enabled. Future updates will install automatically." Then proceed to Step 2. + +**If "Not now":** Write snooze state with escalating backoff (first snooze = 24h, second = 48h, third+ = 1 week), then continue with the current skill. Do not mention the upgrade again. +```bash +_SNOOZE_FILE=~/.gstack/update-snoozed +_REMOTE_VER="{new}" +_CUR_LEVEL=0 +if [ -f "$_SNOOZE_FILE" ]; then + _SNOOZED_VER=$(awk '{print $1}' "$_SNOOZE_FILE") + if [ "$_SNOOZED_VER" = "$_REMOTE_VER" ]; then + _CUR_LEVEL=$(awk '{print $2}' "$_SNOOZE_FILE") + case "$_CUR_LEVEL" in *[!0-9]*) _CUR_LEVEL=0 ;; esac + fi +fi +_NEW_LEVEL=$((_CUR_LEVEL + 1)) +[ "$_NEW_LEVEL" -gt 3 ] && _NEW_LEVEL=3 +echo "$_REMOTE_VER $_NEW_LEVEL $(date +%s)" > "$_SNOOZE_FILE" +``` +Note: `{new}` is the remote version from the `UPGRADE_AVAILABLE` output — substitute it from the update check result. + +Tell user the snooze duration: "Next reminder in 24h" (or 48h or 1 week, depending on level). Tip: "Set `auto_upgrade: true` in `~/.gstack/config.yaml` for automatic upgrades." + +**If "Never ask again":** +```bash +~/.claude/skills/gstack/bin/gstack-config set update_check false +``` +Tell user: "Update checks disabled. Run `~/.claude/skills/gstack/bin/gstack-config set update_check true` to re-enable." +Continue with the current skill. ### Step 2: Detect install type @@ -77,12 +121,40 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` +### Step 4.5: Sync local vendored copy + +After upgrading the primary install, check if there's also a local copy in the current project that needs updating: + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +LOCAL_GSTACK="" +if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then + _RESOLVED_LOCAL=$(cd "$_ROOT/.claude/skills/gstack" && pwd -P) + _RESOLVED_PRIMARY=$(cd "$INSTALL_DIR" && pwd -P) + if [ "$_RESOLVED_LOCAL" != "$_RESOLVED_PRIMARY" ]; then + LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" + fi +fi +echo "LOCAL_GSTACK=$LOCAL_GSTACK" +``` + +If `LOCAL_GSTACK` is non-empty, update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +```bash +mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" +cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" +rm -rf "$LOCAL_GSTACK/.git" +cd "$LOCAL_GSTACK" && ./setup +rm -rf "$LOCAL_GSTACK.bak" +``` +Tell user: "Also updated vendored copy at `$LOCAL_GSTACK` — commit `.claude/skills/gstack/` when you're ready." + ### Step 5: Write marker + clear cache ```bash mkdir -p ~/.gstack echo "$OLD_VERSION" > ~/.gstack/just-upgraded-from rm -f ~/.gstack/last-update-check +rm -f ~/.gstack/update-snoozed ``` ### Step 6: Show What's New diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index dfb7bae5..7bb5dad0 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # Mega Plan Review Mode diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index 88307f60..d8a052ab 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -22,7 +22,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # Plan Review Mode diff --git a/qa/SKILL.md b/qa/SKILL.md index 1a9c1de3..dd4b888d 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # /qa: Systematic QA Testing diff --git a/retro/SKILL.md b/retro/SKILL.md index 38212816..f1e92c20 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -22,7 +22,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # /retro — Weekly Engineering Retrospective diff --git a/review/SKILL.md b/review/SKILL.md index 306794f3..b572283a 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # Pre-Landing PR Review diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index bf142141..7f6bd249 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -102,7 +102,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true \`\`\` -If output shows \`UPGRADE_AVAILABLE \`: read \`~/.claude/skills/gstack/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, \`touch ~/.gstack/last-update-check\` if no). If \`JUST_UPGRADED \`: tell user "Running gstack v{to} (just updated!)" and continue.`; +If output shows \`UPGRADE_AVAILABLE \`: read \`~/.claude/skills/gstack/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If \`JUST_UPGRADED \`: tell user "Running gstack v{to} (just updated!)" and continue.`; } function generateBrowseSetup(): string { diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index e5d3357e..b2f8fc61 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -20,7 +20,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # Setup Browser Cookies diff --git a/ship/SKILL.md b/ship/SKILL.md index 1701f44c..386299b9 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -22,7 +22,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk [ -n "$_UPD" ] && echo "$_UPD" || true ``` -If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. # Ship: Fully Automated Ship Workflow