* 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-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,
|
||||
|
||||
@@ -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]`
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
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-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',
|
||||
|
||||
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,
|
||||
settings: null,
|
||||
statuslineCommand: null,
|
||||
updateBannerCommand: null,
|
||||
runtime: 'trae',
|
||||
configDir: fs.realpathSync(targetDir),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user