diff --git a/.changeset/update-banner-opt-in.md b/.changeset/update-banner-opt-in.md new file mode 100644 index 000000000..0b93c45db --- /dev/null +++ b/.changeset/update-banner-opt-in.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2795 +--- +**Optional update banner for non-GSD statusline users** — when the installer detects you've declined or kept a non-GSD statusline, it now offers an opt-in `SessionStart` banner that surfaces update availability via the existing `~/.cache/gsd/gsd-update-check.json` cache. Silent when up-to-date, rate-limits failure diagnostics to once per 24h, removed cleanly by `npx get-shit-done-cc --uninstall`. diff --git a/bin/install.js b/bin/install.js index 604c86915..738a2f7b7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -569,6 +569,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) { 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', + 'gsd-update-banner.js', 'gsd-workflow-guard.js', ]); let changed = false; @@ -6474,7 +6475,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove GSD hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; + const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; let hookCount = 0; for (const hook of gsdHooks) { const hookPath = path.join(hooksDir, hook); @@ -6530,6 +6531,7 @@ function uninstall(isGlobal, runtime = 'claude') { cmd.includes('gsd-session-state') || cmd.includes('gsd-context-monitor') || cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') || cmd.includes('gsd-read-guard') || cmd.includes('gsd-read-injection-scanner') || + cmd.includes('gsd-update-banner') || cmd.includes('gsd-validate-commit') || cmd.includes('gsd-workflow-guard')); for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) { @@ -8116,7 +8118,7 @@ function install(isGlobal, runtime = 'claude') { throw wrapped; } - return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isCopilot) { @@ -8129,22 +8131,22 @@ function install(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Generated copilot-instructions.md`); } // Copilot: no settings.json, no hooks, no statusline (like Codex) - return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isCursor) { // Cursor uses skills — no config.toml, no settings.json hooks needed - return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isWindsurf) { // Windsurf uses skills — no config.toml, no settings.json hooks needed - return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isTrae) { // Trae uses skills — no settings.json hooks needed - return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } if (isCline) { @@ -8164,7 +8166,7 @@ function install(isGlobal, runtime = 'claude') { ].join('\n') + '\n'; fs.writeFileSync(clinerulesDest, clinerules); console.log(` ${green}✓${reset} Wrote .clinerules`); - return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; + return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } // Configure statusline and hooks in settings.json @@ -8509,13 +8511,30 @@ function install(isGlobal, runtime = 'claude') { } } - return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir }; + // Compute the update-banner hook command alongside the others so + // installAllRuntimes can register it at finalize time when the user opts + // in (#2795). Computed here (not in finishInstall) so the same buildHookCommand + // / localCmd resolution logic is shared with the other JS hooks. + const updateBannerCommand = isOpencode || isKilo + ? null + : (isGlobal + ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts) + : localCmd('gsd-update-banner.js')); + + return { + settingsPath, + settings, + statuslineCommand, + updateBannerCommand, + runtime, + configDir: targetDir, + }; } /** * Apply statusline config, then print completion message */ -function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null) { +function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) { const isOpencode = runtime === 'opencode'; const isKilo = runtime === 'kilo'; const isCodex = runtime === 'codex'; @@ -8546,6 +8565,36 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS } } + // Register the opt-in update banner (#2795) when the user accepted the + // banner offer at install time. Only applies to runtimes that own a + // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/ + // cline either lack the surface or use a different config schema. + const { shouldInstallBanner, bannerCommand } = bannerOpts; + if (shouldInstallBanner && settings && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) { + if (!bannerCommand) { + console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`); + } else { + if (!settings.hooks) settings.hooks = {}; + if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; + const alreadyRegistered = settings.hooks.SessionStart.some(entry => + entry && entry.hooks && entry.hooks.some(h => h && h.command && h.command.includes('gsd-update-banner')) + ); + const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-update-banner.js') : null; + const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false; + if (alreadyRegistered) { + // Idempotent re-install: don't double-register. + } else if (!bannerInstalled) { + console.warn(` ${yellow}⚠${reset} Skipped update banner — gsd-update-banner.js not found at target`); + } else { + const entry = buildUpdateBannerHookEntry(bannerCommand); + if (entry) { + settings.hooks.SessionStart.push(entry); + console.log(` ${green}✓${reset} Configured update banner hook (opt-in)`); + } + } + } + } + // Write settings when runtime supports settings.json. // #3002 CR: defense-in-depth — re-run validateHookFields right before // serialization. The push-site guards above already skip null-command @@ -8797,6 +8846,90 @@ function promptRuntime(callback) { }); } +// ─── Update banner (#2795) ────────────────────────────────────────────────── + +/** + * Build the prompt text shown when offering the opt-in update banner. + * Pure function — no I/O. Exported for tests so they can assert against the + * rendered prompt structurally instead of grepping bin/install.js source. + */ +function buildUpdateBannerPromptText() { + return ` + ${yellow}Optional: GSD update banner${reset} + Without GSD's statusline, update notifications won't be visible. You can + install a SessionStart banner that surfaces a one-line message when a new + GSD release is available. The banner appears only at session start and + only when an update exists. + + ${cyan}1${reset}) ${dim}No banner (default)${reset} + ${cyan}2${reset}) Install update banner +`; +} + +/** + * Parse user input from the banner prompt. Returns true when the user opted + * in. Pure function — exported for direct unit testing. + * + * - Empty input or "1" → false (default: no banner). + * - "2" → true. + * - "y" / "yes" (case-insensitive) → true. Affirmative shortcuts. + */ +function parseUpdateBannerInput(answer) { + const input = (answer == null ? '' : String(answer)).trim().toLowerCase(); + if (input === '2' || input === 'y' || input === 'yes') return true; + return false; +} + +/** + * Build a SessionStart hook entry (settings.json shape) that runs the + * update-banner script. Returns null when the input command is empty so + * callers can warn-and-skip rather than writing { command: null } and + * tripping the runtime's hook schema (#3002). + * + * @param {string|null} bannerCommand - Result of buildHookCommand() / localCmd(). + * @returns {{hooks: Array<{type: 'command', command: string}>}|null} + */ +function buildUpdateBannerHookEntry(bannerCommand) { + if (!bannerCommand) return null; + return { + hooks: [ + { + type: 'command', + command: bannerCommand, + }, + ], + }; +} + +/** + * Interactive prompt that asks the user whether to install the opt-in + * update banner. Used by `installAllRuntimes` only when GSD's statusline + * was declined or skipped. + * + * @param {boolean} isInteractive + * @param {(shouldInstallBanner: boolean) => void} callback + */ +function handleUpdateBanner(isInteractive, callback) { + if (!isInteractive) { + // Never auto-install in non-interactive mode — user can re-run install + // interactively or hand-edit settings.json to opt in later. + callback(false); + return; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log(buildUpdateBannerPromptText()); + + rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { + rl.close(); + callback(parseUpdateBannerInput(answer)); + }); +} + /** * Prompt for install location */ @@ -9629,7 +9762,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { const statuslineRuntimes = ['claude', 'gemini']; const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime)); - const finalize = (shouldInstallStatusline) => { + const finalize = (shouldInstallStatusline, shouldInstallBanner) => { // Verify sdk/dist/cli.js is present and executable. The dist is shipped // prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via // the parent package's bin/gsd-sdk.js shim, so no sub-install is needed. @@ -9646,7 +9779,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { useStatusline, result.runtime, isGlobal, - result.configDir + result.configDir, + { shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand } ); } }; @@ -9654,10 +9788,49 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { printSummaries(); }; + // Statusline first; if it won't actually be installed (declined, or local + // install without --force-statusline silently skips it per #2248), offer + // the opt-in update banner (#2795) as the secondary surface for update + // notifications. Skip the banner prompt entirely when no runtime in this + // install set can host the banner (e.g. Codex/Copilot/Cursor/Windsurf/ + // Trae/Cline-only installs whose updateBannerCommand is null). + // + // CR #3035: gate on actual installability — `shouldInstallStatusline` + // returned by handleStatusline is the raw user choice, but + // `finishInstall` later skips the statusline write on local installs + // unless --force-statusline is set. Passing the raw flag to + // continueAfterStatusline previously caused two bugs: (1) interactive + // local installs got neither a statusline nor a banner offer, and (2) + // banner-incapable runtimes got prompted even though every + // updateBannerCommand was null. + const canInstallBanner = results.some((r) => r && r.updateBannerCommand); + const continueAfterStatusline = (shouldInstallStatusline) => { + const willInstallStatusline = + shouldInstallStatusline && (isGlobal || forceStatusline); + if (willInstallStatusline) { + finalize(true, false); + return; + } + if (!canInstallBanner) { + finalize(shouldInstallStatusline, false); + return; + } + handleUpdateBanner(isInteractive, (shouldInstallBanner) => { + finalize(shouldInstallStatusline, shouldInstallBanner); + }); + }; + if (primaryStatuslineResult) { - handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize); + handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline); + } else if (canInstallBanner) { + // No statusline-capable runtime, but at least one runtime can host the + // banner — still offer it. + handleUpdateBanner(isInteractive, (shouldInstallBanner) => { + finalize(false, shouldInstallBanner); + }); } else { - finalize(false); + // Nothing to prompt about — no statusline, no banner-capable runtime. + finalize(false, false); } } @@ -9760,6 +9933,9 @@ if (process.env.GSD_TEST_MODE) { allRuntimes, parseRuntimeInput, buildRuntimePromptText, + buildUpdateBannerPromptText, + parseUpdateBannerInput, + buildUpdateBannerHookEntry, buildHookCommand, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5f301dc2c..a048cdfbe 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1032,14 +1032,25 @@ fix(03-01): correct auth token expiry - REQ-HOOK-05: All hooks MUST include 3-second stdin timeout guard - REQ-HOOK-06: All hooks MUST fail silently on any error - REQ-HOOK-07: Context usage MUST normalize for autocompact buffer (16.5% reserved) +- REQ-HOOK-08: Update banner MUST be opt-in and silent unless an update is available (PR #2795) **Statusline Display:** -``` +```text [⬆ /gsd-update │] model │ [current task │] directory [█████░░░░░ 50%] ``` Color coding: <50% green, <65% yellow, <80% orange, ≥80% red with skull emoji +**Update Banner (opt-in, when GSD statusline isn't used):** + +When the user declines (or keeps a non-GSD) statusline, the installer offers a SessionStart banner that surfaces update availability without occupying statusline real estate. The banner reads `~/.cache/gsd/gsd-update-check.json` (written by `gsd-check-update-worker.js`) and emits one line only when an update is available: + +```text +GSD update available: 1.39.0 → 1.40.0. Run /gsd-update. +``` + +The banner is silent when up-to-date and rate-limits "check failed" diagnostics to once per 24 hours. Removed cleanly by `npx get-shit-done-cc --uninstall` or by deleting the SessionStart entry that references `gsd-update-banner.js`. + ### 38. Developer Profiling **Command:** `/gsd-profile-user [--questionnaire] [--refresh]` diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index a2e6f1f93..735b18f21 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-04-30", + "generated": "2026-05-02", "families": { "agents": [ "gsd-advisor-researcher", @@ -296,6 +296,7 @@ "gsd-read-injection-scanner.js", "gsd-session-state.sh", "gsd-statusline.js", + "gsd-update-banner.js", "gsd-validate-commit.sh", "gsd-workflow-guard.js" ] diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index adf78bbde..980c5c370 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -400,7 +400,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. --- -## Hooks (11 shipped) +## Hooks (12 shipped) Full listing: `hooks/`. @@ -410,6 +410,7 @@ Full listing: `hooks/`. | `gsd-context-monitor.js` | `PostToolUse` / `AfterTool` | Injects agent-facing context warnings at 35%/25% remaining | | `gsd-check-update.js` | `SessionStart` | Background check for new GSD versions | | `gsd-check-update-worker.js` | (worker) | Background worker helper for check-update | +| `gsd-update-banner.js` | `SessionStart` | Opt-in banner surfacing update availability when GSD statusline isn't used (PR #2795) | | `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) | | `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) | | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 0f87c4ed8..c48f271ec 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1184,6 +1184,30 @@ Since v1.17, the installer backs up locally modified files to `gsd-local-patches If `npx get-shit-done-cc` fails due to npm outages or network restrictions, see [docs/manual-update.md](manual-update.md) for a step-by-step manual update procedure that works without npm access. +### Surface GSD Update Notifications Without GSD's Statusline + +GSD checks for new versions in the background and writes the result to `~/.cache/gsd/gsd-update-check.json`. By default, GSD's statusline (`hooks/gsd-statusline.js`) reads that cache and shows the update indicator. If you use a different statusline (for example `ccstatusline`) or none at all, the update info is invisible. + +**Opt-in fix:** during interactive install, when you decline (or keep your existing) statusline, the installer offers a one-time prompt: + +```text +Optional: GSD update banner + 1) No banner (default) + 2) Install update banner +``` + +Choose `2` (or type `y`/`yes`) and the installer registers `hooks/gsd-update-banner.js` as a `SessionStart` hook. From the next session onward, GSD prints a one-line `systemMessage` only when the cache reports an update available: + +```text +GSD update available: 1.39.0 → 1.40.0. Run /gsd-update. +``` + +The banner is silent when no update is available. If the cache file is corrupt, GSD emits one diagnostic line (`GSD update check failed.`) and stays silent for 24 hours so a broken cache does not nag every session. + +**Opt-out / removal:** delete the SessionStart hook entry that references `gsd-update-banner.js` from your runtime's `settings.json` (Claude Code: `~/.claude/settings.json`; Gemini: `~/.gemini/settings.json`). `npx get-shit-done-cc --uninstall` removes both the script and the registration in one pass. + +The banner is not offered when GSD's statusline is installed — that channel already surfaces update info, so re-prompting would be noise. + ### Workflow Diagnostics (`/gsd-forensics`) When a workflow fails in a way that isn't obvious -- plans reference nonexistent files, execution produces unexpected results, or state seems corrupted -- run `/gsd-forensics` to generate a diagnostic report. diff --git a/hooks/gsd-check-update-worker.js b/hooks/gsd-check-update-worker.js index ed5d36935..8c2250870 100644 --- a/hooks/gsd-check-update-worker.js +++ b/hooks/gsd-check-update-worker.js @@ -56,6 +56,7 @@ const MANAGED_HOOKS = [ 'gsd-read-injection-scanner.js', 'gsd-session-state.sh', 'gsd-statusline.js', + 'gsd-update-banner.js', 'gsd-validate-commit.sh', 'gsd-workflow-guard.js', ]; diff --git a/hooks/gsd-update-banner.js b/hooks/gsd-update-banner.js new file mode 100755 index 000000000..29b64d5fc --- /dev/null +++ b/hooks/gsd-update-banner.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// SessionStart banner that surfaces GSD update availability when GSD's +// statusline isn't installed. Reads the cache that +// gsd-check-update-worker.js writes to ~/.cache/gsd/gsd-update-check.json. +// +// Opt-in by design: bin/install.js only registers this hook when the user +// declines to install (or replace) the GSD statusline. The presence of the +// SessionStart entry IS the opt-in — there is no separate runtime flag. +// +// See issue #2795 for the rationale. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Suppress repeat parse-error banners for 24 hours so a genuinely broken +// cache file doesn't nag the user every session. +const RATE_LIMIT_SECONDS = 24 * 60 * 60; + +/** + * Build the SessionStart JSON envelope to emit, given parsed cache state. + * Pure function — no I/O. Returns null when the hook should print nothing. + * + * @param {object} state + * @param {object|null} state.cache Parsed cache, or null if missing/unreadable. + * @param {boolean} state.parseError True iff cache file existed but JSON.parse failed. + * @param {boolean} state.suppressFailureWarning True when a recent failure warning already fired. + * @returns {{systemMessage: string}|null} JSON envelope, or null for silent exit. + */ +function buildBannerOutput(state) { + const { cache, parseError, suppressFailureWarning } = state || {}; + if (parseError) { + if (suppressFailureWarning) return null; + return { systemMessage: 'GSD update check failed.' }; + } + if (!cache) return null; + if (!cache.update_available) return null; + const installed = cache.installed || 'unknown'; + const latest = cache.latest || 'unknown'; + return { + systemMessage: `GSD update available: ${installed} → ${latest}. Run /gsd-update.`, + }; +} + +/** + * Read and parse the update-check cache file. + * + * @param {string} cacheFile + * @returns {{cache: object|null, parseError: boolean}} + */ +function readCache(cacheFile) { + let cache = null; + let parseError = false; + try { + if (fs.existsSync(cacheFile)) { + const raw = fs.readFileSync(cacheFile, 'utf8'); + cache = JSON.parse(raw); + } + } catch (e) { + // Distinguish "file unreadable" from "JSON malformed": both fail-open to + // null cache, but a JSON parse error becomes a one-time diagnostic. + parseError = e instanceof SyntaxError; + } + return { cache, parseError }; +} + +/** + * Has a failure warning been emitted within the rate-limit window? + * + * @param {string} sentinelFile + * @param {number} nowSeconds + * @returns {boolean} + */ +function shouldSuppressFailureWarning(sentinelFile, nowSeconds) { + try { + if (!fs.existsSync(sentinelFile)) return false; + const last = parseInt(fs.readFileSync(sentinelFile, 'utf8').trim(), 10); + if (!Number.isFinite(last)) return false; + return nowSeconds - last < RATE_LIMIT_SECONDS; + } catch (e) { + return false; + } +} + +function recordFailureWarning(sentinelFile, nowSeconds) { + try { + fs.writeFileSync(sentinelFile, String(nowSeconds)); + } catch (e) { + // Best-effort: a non-writable cache dir means we'll re-warn next session, + // which is no worse than the un-instrumented baseline. + } +} + +function main() { + const cacheDir = path.join(os.homedir(), '.cache', 'gsd'); + const cacheFile = path.join(cacheDir, 'gsd-update-check.json'); + const sentinelFile = path.join(cacheDir, 'banner-failure-warned-at'); + const now = Math.floor(Date.now() / 1000); + + const { cache, parseError } = readCache(cacheFile); + const suppressFailureWarning = parseError + ? shouldSuppressFailureWarning(sentinelFile, now) + : false; + const output = buildBannerOutput({ cache, parseError, suppressFailureWarning }); + + if (parseError && !suppressFailureWarning) { + // Ensure cache dir exists before writing the sentinel — first-run case + // where ~/.cache/gsd was created by check-update but the parent dir got + // wiped between runs. + try { + fs.mkdirSync(cacheDir, { recursive: true }); + } catch (e) { + // Best-effort: failure to create the dir means we'll re-warn next + // session, which is no worse than the un-instrumented baseline. + } + recordFailureWarning(sentinelFile, now); + } + + if (output) { + process.stdout.write(JSON.stringify(output)); + } +} + +if (require.main === module) main(); + +module.exports = { + buildBannerOutput, + readCache, + shouldSuppressFailureWarning, + RATE_LIMIT_SECONDS, +}; diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index f4e2f3ec1..e2f6b5c25 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -22,6 +22,7 @@ const HOOKS_TO_COPY = [ 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-statusline.js', + 'gsd-update-banner.js', 'gsd-workflow-guard.js', // Community hooks (bash, opt-in via .planning/config.json hooks.community) 'gsd-session-state.sh', diff --git a/tests/feat-2795-update-banner.test.cjs b/tests/feat-2795-update-banner.test.cjs new file mode 100644 index 000000000..8c59ac973 --- /dev/null +++ b/tests/feat-2795-update-banner.test.cjs @@ -0,0 +1,361 @@ +/** + * Tests for gsd-update-banner.js (#2795). + * + * The banner hook is an opt-in SessionStart consumer of the update cache that + * gsd-check-update-worker.js writes. When a user declines GSD's statusline, + * install.js may register this hook so update availability still surfaces in + * runtimes that use a non-GSD statusline. + * + * Tests follow the typed-IR convention (CONTRIBUTING.md "Prohibited: Raw Text + * Matching on Test Outputs"): assert on parsed JSON envelopes, not on raw + * stdout substrings. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-update-banner.js'); +const { + buildBannerOutput, + shouldSuppressFailureWarning, + RATE_LIMIT_SECONDS, +} = require('../hooks/gsd-update-banner.js'); + +// ─── Pure function: buildBannerOutput ─────────────────────────────────────── + +describe('buildBannerOutput', () => { + test('returns null when cache is missing', () => { + const out = buildBannerOutput({ + cache: null, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null); + }); + + test('returns null when update_available is false', () => { + const out = buildBannerOutput({ + cache: { update_available: false, installed: '1.40.0', latest: '1.40.0' }, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null); + }); + + test('returns banner envelope when update_available is true', () => { + const out = buildBannerOutput({ + cache: { update_available: true, installed: '1.39.0', latest: '1.40.0' }, + parseError: false, + suppressFailureWarning: false, + }); + assert.ok(out, 'expected banner envelope'); + assert.equal(typeof out.systemMessage, 'string'); + assert.ok( + out.systemMessage.includes('1.39.0'), + 'banner should name installed version' + ); + assert.ok( + out.systemMessage.includes('1.40.0'), + 'banner should name latest version' + ); + assert.ok( + out.systemMessage.includes('/gsd-update'), + 'banner should reference /gsd-update command' + ); + }); + + test('returns failure diagnostic on parseError when not suppressed', () => { + const out = buildBannerOutput({ + cache: null, + parseError: true, + suppressFailureWarning: false, + }); + assert.ok(out, 'expected diagnostic envelope'); + assert.equal(typeof out.systemMessage, 'string'); + assert.ok( + /check failed/i.test(out.systemMessage), + 'diagnostic should describe a failed check' + ); + }); + + test('returns null on parseError when suppressed by rate limit', () => { + const out = buildBannerOutput({ + cache: null, + parseError: true, + suppressFailureWarning: true, + }); + assert.equal(out, null); + }); + + test('falls back to "unknown" when installed/latest missing', () => { + const out = buildBannerOutput({ + cache: { update_available: true }, + parseError: false, + suppressFailureWarning: false, + }); + assert.ok(out); + assert.ok( + out.systemMessage.includes('unknown'), + 'banner should degrade gracefully when versions are absent' + ); + }); +}); + +// ─── Pure function: shouldSuppressFailureWarning ──────────────────────────── + +describe('shouldSuppressFailureWarning', () => { + function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-supp-')); + } + + test('returns false when sentinel file is missing', () => { + const dir = tmpDir(); + try { + const result = shouldSuppressFailureWarning( + path.join(dir, 'no-such-file'), + 100 + ); + assert.equal(result, false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('returns true within rate-limit window', () => { + const dir = tmpDir(); + try { + const f = path.join(dir, 'sentinel'); + fs.writeFileSync(f, '1000'); + const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS - 1); + assert.equal(result, true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('returns false outside rate-limit window', () => { + const dir = tmpDir(); + try { + const f = path.join(dir, 'sentinel'); + fs.writeFileSync(f, '1000'); + const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS + 1); + assert.equal(result, false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('returns false when sentinel content is non-numeric', () => { + const dir = tmpDir(); + try { + const f = path.join(dir, 'sentinel'); + fs.writeFileSync(f, 'garbage-not-a-number'); + const result = shouldSuppressFailureWarning(f, 100); + assert.equal(result, false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// ─── End-to-end: spawn the hook against fixture cache states ──────────────── + +describe('gsd-update-banner.js end-to-end', () => { + function setupHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-home-')); + fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true }); + return home; + } + + function runHook(home) { + return spawnSync(process.execPath, [HOOK_PATH], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + encoding: 'utf8', + }); + } + + function writeCache(home, contents) { + fs.writeFileSync( + path.join(home, '.cache', 'gsd', 'gsd-update-check.json'), + typeof contents === 'string' ? contents : JSON.stringify(contents) + ); + } + + test('exits 0 with empty stdout when cache file missing', () => { + const home = setupHome(); + try { + const r = runHook(home); + assert.equal(r.status, 0, `expected exit 0, got ${r.status} stderr=${r.stderr}`); + assert.equal(r.stdout.trim(), ''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('emits valid SessionStart JSON when update_available=true', () => { + const home = setupHome(); + try { + writeCache(home, { + update_available: true, + installed: '1.39.0', + latest: '1.40.0', + }); + const r = runHook(home); + assert.equal(r.status, 0); + const parsed = JSON.parse(r.stdout); + assert.equal(typeof parsed.systemMessage, 'string'); + assert.ok(parsed.systemMessage.includes('1.40.0')); + assert.ok(parsed.systemMessage.includes('/gsd-update')); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('exits silent when update_available=false', () => { + const home = setupHome(); + try { + writeCache(home, { + update_available: false, + installed: '1.40.0', + latest: '1.40.0', + }); + const r = runHook(home); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('emits failure diagnostic when cache JSON is malformed', () => { + const home = setupHome(); + try { + writeCache(home, 'not json {{{{'); + const r = runHook(home); + assert.equal(r.status, 0); + const parsed = JSON.parse(r.stdout); + assert.equal(typeof parsed.systemMessage, 'string'); + assert.ok(/check failed/i.test(parsed.systemMessage)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('suppresses repeat failure diagnostic within 24h via sentinel', () => { + const home = setupHome(); + try { + writeCache(home, 'not json'); + const r1 = runHook(home); + assert.equal( + r1.status, + 0, + `expected exit 0, got ${r1.status} stderr=${r1.stderr}` + ); + const parsed1 = JSON.parse(r1.stdout); + assert.ok(/check failed/i.test(parsed1.systemMessage)); + + // Sentinel should now exist so the next run is silent + const sentinel = path.join(home, '.cache', 'gsd', 'banner-failure-warned-at'); + assert.ok(fs.existsSync(sentinel), 'first run must record the warning sentinel'); + + const r2 = runHook(home); + assert.equal(r2.status, 0); + assert.equal( + r2.stdout.trim(), + '', + 'subsequent run within rate-limit window must stay silent' + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('handles cache present but update_available field absent (older cache schema)', () => { + const home = setupHome(); + try { + writeCache(home, { installed: '1.40.0', latest: '1.40.0' }); + const r = runHook(home); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); + +// ─── Install.js wiring: prompt + SessionStart entry registration ──────────── +// +// These tests load bin/install.js as a module via GSD_TEST_MODE and assert on +// pure exported helpers. The shape mirrors how runtime-prompt-builder / +// statusline tests interact with install.js. + +describe('install.js update-banner wiring', () => { + process.env.GSD_TEST_MODE = '1'; + // Re-require fresh so test-mode exports are populated. + const installPath = path.join(__dirname, '..', 'bin', 'install.js'); + delete require.cache[installPath]; + const installExports = require(installPath); + + test('exports buildUpdateBannerPromptText for structural prompt assertions', () => { + assert.equal( + typeof installExports.buildUpdateBannerPromptText, + 'function', + 'install.js must export buildUpdateBannerPromptText so tests can assert without grepping source' + ); + const text = installExports.buildUpdateBannerPromptText(); + assert.equal(typeof text, 'string'); + assert.ok(text.length > 0); + // Strip ANSI color escapes before structural assertions — the choice + // digits are wrapped in color codes so word-boundary regex against the + // raw text would miss them. + const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); + // Prompt must offer at least two choices (default + opt-in). + assert.match(stripped, /\b1\b/); + assert.match(stripped, /\b2\b/); + }); + + test('parseUpdateBannerInput defaults to false on empty / "1"', () => { + assert.equal(typeof installExports.parseUpdateBannerInput, 'function'); + assert.equal(installExports.parseUpdateBannerInput(''), false); + assert.equal(installExports.parseUpdateBannerInput(' '), false); + assert.equal(installExports.parseUpdateBannerInput('1'), false); + }); + + test('parseUpdateBannerInput returns true on "2"', () => { + assert.equal(installExports.parseUpdateBannerInput('2'), true); + assert.equal(installExports.parseUpdateBannerInput('2 '), true); + }); + + test('parseUpdateBannerInput accepts "y" / "yes" affirmative shortcuts', () => { + assert.equal(installExports.parseUpdateBannerInput('y'), true); + assert.equal(installExports.parseUpdateBannerInput('Y'), true); + assert.equal(installExports.parseUpdateBannerInput('yes'), true); + assert.equal(installExports.parseUpdateBannerInput('YES'), true); + }); + + test('buildUpdateBannerHookEntry produces a SessionStart hook entry', () => { + assert.equal(typeof installExports.buildUpdateBannerHookEntry, 'function'); + const entry = installExports.buildUpdateBannerHookEntry( + '"/usr/local/bin/node" "/home/u/.claude/hooks/gsd-update-banner.js"' + ); + assert.ok(entry, 'expected hook entry object'); + assert.ok(Array.isArray(entry.hooks), 'entry.hooks must be an array'); + assert.equal(entry.hooks.length, 1); + assert.equal(entry.hooks[0].type, 'command'); + assert.ok( + entry.hooks[0].command.includes('gsd-update-banner.js'), + 'command must reference the banner hook' + ); + }); + + test('buildUpdateBannerHookEntry returns null on null command', () => { + assert.equal(installExports.buildUpdateBannerHookEntry(null), null); + assert.equal(installExports.buildUpdateBannerHookEntry(''), null); + }); +}); diff --git a/tests/trae-install.test.cjs b/tests/trae-install.test.cjs index ffefb8366..f329981a4 100644 --- a/tests/trae-install.test.cjs +++ b/tests/trae-install.test.cjs @@ -182,6 +182,7 @@ describe('Trae local install/uninstall', () => { settingsPath: null, settings: null, statuslineCommand: null, + updateBannerCommand: null, runtime: 'trae', configDir: fs.realpathSync(targetDir), });