* 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>
This commit is contained in:
5
.changeset/update-banner-opt-in.md
Normal file
5
.changeset/update-banner-opt-in.md
Normal file
@@ -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`.
|
||||||
202
bin/install.js
202
bin/install.js
@@ -569,6 +569,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
|
|||||||
'gsd-prompt-guard.js',
|
'gsd-prompt-guard.js',
|
||||||
'gsd-read-guard.js',
|
'gsd-read-guard.js',
|
||||||
'gsd-read-injection-scanner.js',
|
'gsd-read-injection-scanner.js',
|
||||||
|
'gsd-update-banner.js',
|
||||||
'gsd-workflow-guard.js',
|
'gsd-workflow-guard.js',
|
||||||
]);
|
]);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -6474,7 +6475,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|||||||
// 4. Remove GSD hooks
|
// 4. Remove GSD hooks
|
||||||
const hooksDir = path.join(targetDir, 'hooks');
|
const hooksDir = path.join(targetDir, 'hooks');
|
||||||
if (fs.existsSync(hooksDir)) {
|
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;
|
let hookCount = 0;
|
||||||
for (const hook of gsdHooks) {
|
for (const hook of gsdHooks) {
|
||||||
const hookPath = path.join(hooksDir, hook);
|
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-session-state') || cmd.includes('gsd-context-monitor') ||
|
||||||
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
|
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
|
||||||
cmd.includes('gsd-read-guard') || cmd.includes('gsd-read-injection-scanner') ||
|
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'));
|
cmd.includes('gsd-validate-commit') || cmd.includes('gsd-workflow-guard'));
|
||||||
|
|
||||||
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) {
|
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) {
|
||||||
@@ -8116,7 +8118,7 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
throw wrapped;
|
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) {
|
if (isCopilot) {
|
||||||
@@ -8129,22 +8131,22 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
||||||
}
|
}
|
||||||
// Copilot: no settings.json, no hooks, no statusline (like Codex)
|
// 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) {
|
if (isCursor) {
|
||||||
// Cursor uses skills — no config.toml, no settings.json hooks needed
|
// 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) {
|
if (isWindsurf) {
|
||||||
// Windsurf uses skills — no config.toml, no settings.json hooks needed
|
// 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) {
|
if (isTrae) {
|
||||||
// Trae uses skills — no settings.json hooks needed
|
// 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) {
|
if (isCline) {
|
||||||
@@ -8164,7 +8166,7 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
].join('\n') + '\n';
|
].join('\n') + '\n';
|
||||||
fs.writeFileSync(clinerulesDest, clinerules);
|
fs.writeFileSync(clinerulesDest, clinerules);
|
||||||
console.log(` ${green}✓${reset} Wrote .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
|
// 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
|
* 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 isOpencode = runtime === 'opencode';
|
||||||
const isKilo = runtime === 'kilo';
|
const isKilo = runtime === 'kilo';
|
||||||
const isCodex = runtime === 'codex';
|
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.
|
// Write settings when runtime supports settings.json.
|
||||||
// #3002 CR: defense-in-depth — re-run validateHookFields right before
|
// #3002 CR: defense-in-depth — re-run validateHookFields right before
|
||||||
// serialization. The push-site guards above already skip null-command
|
// 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
|
* Prompt for install location
|
||||||
*/
|
*/
|
||||||
@@ -9629,7 +9762,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
|||||||
const statuslineRuntimes = ['claude', 'gemini'];
|
const statuslineRuntimes = ['claude', 'gemini'];
|
||||||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
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
|
// 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
|
// 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.
|
// 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,
|
useStatusline,
|
||||||
result.runtime,
|
result.runtime,
|
||||||
isGlobal,
|
isGlobal,
|
||||||
result.configDir
|
result.configDir,
|
||||||
|
{ shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -9654,10 +9788,49 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
|||||||
printSummaries();
|
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) {
|
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 {
|
} 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,
|
allRuntimes,
|
||||||
parseRuntimeInput,
|
parseRuntimeInput,
|
||||||
buildRuntimePromptText,
|
buildRuntimePromptText,
|
||||||
|
buildUpdateBannerPromptText,
|
||||||
|
parseUpdateBannerInput,
|
||||||
|
buildUpdateBannerHookEntry,
|
||||||
buildHookCommand,
|
buildHookCommand,
|
||||||
resolveNodeRunner,
|
resolveNodeRunner,
|
||||||
rewriteLegacyManagedNodeHookCommands,
|
rewriteLegacyManagedNodeHookCommands,
|
||||||
|
|||||||
@@ -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-05: All hooks MUST include 3-second stdin timeout guard
|
||||||
- REQ-HOOK-06: All hooks MUST fail silently on any error
|
- 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-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:**
|
**Statusline Display:**
|
||||||
```
|
```text
|
||||||
[⬆ /gsd-update │] model │ [current task │] directory [█████░░░░░ 50%]
|
[⬆ /gsd-update │] model │ [current task │] directory [█████░░░░░ 50%]
|
||||||
```
|
```
|
||||||
|
|
||||||
Color coding: <50% green, <65% yellow, <80% orange, ≥80% red with skull emoji
|
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
|
### 38. Developer Profiling
|
||||||
|
|
||||||
**Command:** `/gsd-profile-user [--questionnaire] [--refresh]`
|
**Command:** `/gsd-profile-user [--questionnaire] [--refresh]`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generated": "2026-04-30",
|
"generated": "2026-05-02",
|
||||||
"families": {
|
"families": {
|
||||||
"agents": [
|
"agents": [
|
||||||
"gsd-advisor-researcher",
|
"gsd-advisor-researcher",
|
||||||
@@ -296,6 +296,7 @@
|
|||||||
"gsd-read-injection-scanner.js",
|
"gsd-read-injection-scanner.js",
|
||||||
"gsd-session-state.sh",
|
"gsd-session-state.sh",
|
||||||
"gsd-statusline.js",
|
"gsd-statusline.js",
|
||||||
|
"gsd-update-banner.js",
|
||||||
"gsd-validate-commit.sh",
|
"gsd-validate-commit.sh",
|
||||||
"gsd-workflow-guard.js"
|
"gsd-workflow-guard.js"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hooks (11 shipped)
|
## Hooks (12 shipped)
|
||||||
|
|
||||||
Full listing: `hooks/`.
|
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-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.js` | `SessionStart` | Background check for new GSD versions |
|
||||||
| `gsd-check-update-worker.js` | (worker) | Background worker helper for check-update |
|
| `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-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-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 |
|
| `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files |
|
||||||
|
|||||||
@@ -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.
|
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`)
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const MANAGED_HOOKS = [
|
|||||||
'gsd-read-injection-scanner.js',
|
'gsd-read-injection-scanner.js',
|
||||||
'gsd-session-state.sh',
|
'gsd-session-state.sh',
|
||||||
'gsd-statusline.js',
|
'gsd-statusline.js',
|
||||||
|
'gsd-update-banner.js',
|
||||||
'gsd-validate-commit.sh',
|
'gsd-validate-commit.sh',
|
||||||
'gsd-workflow-guard.js',
|
'gsd-workflow-guard.js',
|
||||||
];
|
];
|
||||||
|
|||||||
134
hooks/gsd-update-banner.js
Executable file
134
hooks/gsd-update-banner.js
Executable file
@@ -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,
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ const HOOKS_TO_COPY = [
|
|||||||
'gsd-read-guard.js',
|
'gsd-read-guard.js',
|
||||||
'gsd-read-injection-scanner.js',
|
'gsd-read-injection-scanner.js',
|
||||||
'gsd-statusline.js',
|
'gsd-statusline.js',
|
||||||
|
'gsd-update-banner.js',
|
||||||
'gsd-workflow-guard.js',
|
'gsd-workflow-guard.js',
|
||||||
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
|
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
|
||||||
'gsd-session-state.sh',
|
'gsd-session-state.sh',
|
||||||
|
|||||||
361
tests/feat-2795-update-banner.test.cjs
Normal file
361
tests/feat-2795-update-banner.test.cjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -182,6 +182,7 @@ describe('Trae local install/uninstall', () => {
|
|||||||
settingsPath: null,
|
settingsPath: null,
|
||||||
settings: null,
|
settings: null,
|
||||||
statuslineCommand: null,
|
statuslineCommand: null,
|
||||||
|
updateBannerCommand: null,
|
||||||
runtime: 'trae',
|
runtime: 'trae',
|
||||||
configDir: fs.realpathSync(targetDir),
|
configDir: fs.realpathSync(targetDir),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user