* 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>
97 lines
2.6 KiB
JavaScript
97 lines
2.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Copy GSD hooks to dist for installation.
|
|
* Validates JavaScript syntax before copying to prevent shipping broken hooks.
|
|
* See #1107, #1109, #1125, #1161 — a duplicate const declaration shipped
|
|
* in dist and caused PostToolUse hook errors for all users.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
|
|
|
|
// Hooks to copy (pure Node.js, no bundling needed)
|
|
const HOOKS_TO_COPY = [
|
|
'gsd-check-update-worker.js',
|
|
'gsd-check-update.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'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',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh'
|
|
];
|
|
|
|
/**
|
|
* Validate JavaScript syntax without executing the file.
|
|
* Catches SyntaxError (duplicate const, missing brackets, etc.)
|
|
* before the hook gets shipped to users.
|
|
*/
|
|
function validateSyntax(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
try {
|
|
// Use vm.compileFunction to check syntax without executing
|
|
new vm.Script(content, { filename: path.basename(filePath) });
|
|
return null; // No error
|
|
} catch (e) {
|
|
if (e instanceof SyntaxError) {
|
|
return e.message;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function build() {
|
|
// Ensure dist directory exists
|
|
if (!fs.existsSync(DIST_DIR)) {
|
|
fs.mkdirSync(DIST_DIR, { recursive: true });
|
|
}
|
|
|
|
let hasErrors = false;
|
|
|
|
// Copy hooks to dist with syntax validation
|
|
for (const hook of HOOKS_TO_COPY) {
|
|
const src = path.join(HOOKS_DIR, hook);
|
|
const dest = path.join(DIST_DIR, hook);
|
|
|
|
if (!fs.existsSync(src)) {
|
|
console.warn(`Warning: ${hook} not found, skipping`);
|
|
continue;
|
|
}
|
|
|
|
// Validate JS syntax before copying (.sh files skip — not Node.js)
|
|
if (hook.endsWith('.js')) {
|
|
const syntaxError = validateSyntax(src);
|
|
if (syntaxError) {
|
|
console.error(`\x1b[31m✗ ${hook}: SyntaxError — ${syntaxError}\x1b[0m`);
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
console.log(`\x1b[32m✓\x1b[0m Copying ${hook}...`);
|
|
fs.copyFileSync(src, dest);
|
|
// Preserve executable bit for shell scripts
|
|
if (hook.endsWith('.sh')) {
|
|
try { fs.chmodSync(dest, 0o755); } catch (e) { /* Windows */ }
|
|
}
|
|
}
|
|
|
|
if (hasErrors) {
|
|
console.error('\n\x1b[31mBuild failed: fix syntax errors above before publishing.\x1b[0m');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\nBuild complete.');
|
|
}
|
|
|
|
build();
|