Files
msd-core/hooks/gsd-update-banner.js
Tom Boucher 95d2bc20f8 feat(hooks): opt-in SessionStart update banner for non-statusline users (#2795) (#3035)
* feat(hooks): opt-in SessionStart update banner for non-statusline users (#2795)

When a user declines (or keeps a non-GSD) statusline at install time, the
installer now offers an opt-in SessionStart banner that surfaces GSD update
availability. The banner reads the existing
~/.cache/gsd/gsd-update-check.json cache (written by
gsd-check-update-worker.js) and emits a single systemMessage line only when
update_available is true:

    GSD update available: <installed> → <latest>. Run /gsd-update.

It is silent when up-to-date and rate-limits "check failed" diagnostics to
once per 24h via a sentinel file so a corrupt cache doesn't nag every
session. Removed cleanly by `npx get-shit-done-cc --uninstall` which strips
both the script and the SessionStart entry. The banner is never offered when
GSD's statusline is being installed (statusline already surfaces update
info, so re-prompting would be noise).

Implementation:
- hooks/gsd-update-banner.js — pure functions buildBannerOutput,
  shouldSuppressFailureWarning, readCache; thin main() wires them.
- bin/install.js — handleUpdateBanner() prompt, parseUpdateBannerInput(),
  buildUpdateBannerHookEntry(), buildUpdateBannerPromptText(); chained into
  installAllRuntimes() so finalize() receives both flags. updateBannerCommand
  computed alongside the other JS-hook commands; finishInstall() registers
  the SessionStart entry only when shouldInstallBanner === true and the
  hook file is present at the target.
- Hook ships in scripts/build-hooks.js HOOKS_TO_COPY, listed in
  MANAGED_HOOKS for stale-detection in gsd-check-update-worker.js, in the
  uninstall hook-removal lists in install.js, and in the
  rewriteLegacyManagedNodeHookCommands allowlist.

Tests:
- tests/feat-2795-update-banner.test.cjs — 22 tests, structural-IR
  assertions on parsed JSON envelopes (no raw-text matching). Covers
  pure-function branches (cache present/absent, parseError, rate-limit
  suppression, missing version fields), end-to-end hook invocation against
  fixture cache states, and install.js wiring (prompt text, input parsing,
  hook entry shape).
- tests/trae-install.test.cjs — updated install() return-shape assertion to
  include updateBannerCommand: null for the no-settings runtime.
- 6881/6881 tests pass.

Docs (bundled in same commit per the bundle-docs-with-code skill):
- docs/USER-GUIDE.md — new "Surface GSD Update Notifications Without GSD's
  Statusline" task section with opt-in/opt-out instructions.
- docs/FEATURES.md — REQ-HOOK-08 added; "Update Banner" subsection under
  the Hook System feature with cache flow + removal path.
- docs/INVENTORY.md — hook count 11 → 12, new row for gsd-update-banner.js.
- docs/INVENTORY-MANIFEST.json — regenerated.

Closes #2795

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(install): gate banner prompt on actual installability (CR #3035)

CodeRabbit findings on PR #3035:

- bin/install.js (Major): continueAfterStatusline gated banner prompt on
  the raw `shouldInstallStatusline` flag from handleStatusline. But
  finishInstall later silently skips the statusline write on local
  installs unless --force-statusline is set (#2248). Two consequences:
    1. Interactive local Claude/Gemini installs got neither a statusline
       nor a banner offer.
    2. Codex/Cursor/Copilot/Windsurf/Trae/Cline-only installs (where
       every result.updateBannerCommand is null) still got prompted even
       though the choice was silently ignored.
  Fix: derive willInstallStatusline = shouldInstallStatusline &&
  (isGlobal || forceStatusline), and gate the banner prompt on a
  canInstallBanner precondition computed from results[].updateBannerCommand.
  Pass the raw shouldInstallStatusline through to finalize unchanged so
  per-runtime statusline gating in finishInstall is unaffected.

- tests/feat-2795-update-banner.test.cjs (Minor): rate-limit suppression
  test parsed r1.stdout without first asserting r1.status === 0. Other
  e2e tests in this file (lines 210, 241) do this. A non-zero exit would
  surface as a cryptic SyntaxError instead of a status assertion failure.
  Fix applied verbatim.

6881/6881 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:33:16 -04:00

135 lines
4.4 KiB
JavaScript
Executable File

#!/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,
};