fix(install): comprehensive audit cleanup of hook copy, uninstall, and manifest (#1755) (#1768)

- Add chmod +x for .sh hooks during install (fixes #1755 permission denied)
- Fix Codex hook: wrong path (get-shit-done/hooks/) and inverted filename (gsd-update-check.js → gsd-check-update.js)
- Fix cache invalidation path from ~/cache/ to ~/.cache/gsd/
- Track .sh hooks in writeManifest so saveLocalPatches detects modifications
- Add gsd-workflow-guard.js to uninstall file cleanup list
- Add community hooks (session-state, validate-commit, phase-boundary) to uninstall settings.json cleanup
- Remove phantom gsd-check-update.sh from uninstall list
- Remove dead isCursor/isWindsurf branches in uninstall (already handled by combined branch)
- Warn when expected .sh hooks are missing after verifyInstalled
- Add 15 regression tests in install-hooks-copy.test.cjs
- Update codex-config.test.cjs assertions for corrected hook filename

Fixes #1755
This commit is contained in:
Jeremy McSpadden
2026-04-05 10:37:27 -05:00
committed by GitHub
parent c7d25b183a
commit 84de0cc760
4 changed files with 404 additions and 64 deletions

View File

@@ -4260,40 +4260,6 @@ function uninstall(isGlobal, runtime = 'claude') {
console.log(` ${green}✓${reset} Removed ${skillCount} Antigravity skills`);
}
}
} else if (isCursor) {
// Cursor: remove skills/gsd-*/ directories (same layout as Codex skills)
const skillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(skillsDir)) {
let skillCount = 0;
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
skillCount++;
}
}
if (skillCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${skillCount} Cursor skills`);
}
}
} else if (isWindsurf) {
// Windsurf: remove skills/gsd-*/ directories (same layout as Cursor skills)
const skillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(skillsDir)) {
let skillCount = 0;
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
skillCount++;
}
}
if (skillCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${skillCount} Windsurf skills`);
}
}
} else if (isGemini) {
// Gemini: still uses commands/gsd/
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
@@ -4403,7 +4369,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-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-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-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);
@@ -4459,7 +4425,7 @@ function uninstall(isGlobal, runtime = 'claude') {
if (entry.hooks && Array.isArray(entry.hooks)) {
// Filter out GSD hooks
const hasGsdHook = entry.hooks.some(h =>
h.command && (h.command.includes('gsd-check-update') || h.command.includes('gsd-statusline'))
h.command && (h.command.includes('gsd-check-update') || h.command.includes('gsd-statusline') || h.command.includes('gsd-session-state'))
);
return !hasGsdHook;
}
@@ -4482,7 +4448,7 @@ function uninstall(isGlobal, runtime = 'claude') {
settings.hooks[eventName] = settings.hooks[eventName].filter(entry => {
if (entry.hooks && Array.isArray(entry.hooks)) {
const hasGsdHook = entry.hooks.some(h =>
h.command && h.command.includes('gsd-context-monitor')
h.command && (h.command.includes('gsd-context-monitor') || h.command.includes('gsd-phase-boundary'))
);
return !hasGsdHook;
}
@@ -4505,7 +4471,7 @@ function uninstall(isGlobal, runtime = 'claude') {
settings.hooks[eventName] = settings.hooks[eventName].filter(entry => {
if (entry.hooks && Array.isArray(entry.hooks)) {
const hasGsdHook = entry.hooks.some(h =>
h.command && (h.command.includes('gsd-prompt-guard') || h.command.includes('gsd-read-guard'))
h.command && (h.command.includes('gsd-prompt-guard') || h.command.includes('gsd-read-guard') || h.command.includes('gsd-validate-commit'))
);
return !hasGsdHook;
}
@@ -4971,7 +4937,7 @@ function writeManifest(configDir, runtime = 'claude') {
const hooksDir = path.join(configDir, 'hooks');
if (fs.existsSync(hooksDir)) {
for (const file of fs.readdirSync(hooksDir)) {
if (file.startsWith('gsd-') && file.endsWith('.js')) {
if (file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))) {
manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
}
}
@@ -5392,11 +5358,22 @@ function install(isGlobal, runtime = 'claude') {
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
} else {
fs.copyFileSync(srcFile, destFile);
// Ensure .sh hook files are executable (mirrors chmod in build-hooks.js)
if (entry.endsWith('.sh')) {
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
}
}
}
}
if (verifyInstalled(hooksDest, 'hooks')) {
console.log(` ${green}✓${reset} Installed hooks (bundled)`);
// Warn if expected community .sh hooks are missing (non-fatal)
const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
for (const sh of expectedShHooks) {
if (!fs.existsSync(path.join(hooksDest, sh))) {
console.warn(` ${yellow}⚠${reset} Missing expected hook: ${sh}`);
}
}
} else {
failures.push('hooks');
}
@@ -5404,8 +5381,8 @@ function install(isGlobal, runtime = 'claude') {
}
// Clear stale update cache so next session re-evaluates hook versions
// targetDir is e.g. ~/.claude/get-shit-done/, parent is the config dir
const updateCacheFile = path.join(path.dirname(targetDir), 'cache', 'gsd-update-check.json');
// Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36)
const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json');
try { fs.unlinkSync(updateCacheFile); } catch (e) { /* cache may not exist yet */ }
if (failures.length > 0) {
@@ -5484,14 +5461,14 @@ function install(isGlobal, runtime = 'claude') {
configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership);
// Add SessionStart hook for update checking
const updateCheckScript = path.resolve(targetDir, 'get-shit-done', 'hooks', 'gsd-update-check.js').replace(/\\/g, '/');
const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
const hookBlock =
`${eol}# GSD Hooks${eol}` +
`[[hooks]]${eol}` +
`event = "SessionStart"${eol}` +
`command = "node ${updateCheckScript}"${eol}`;
if (hasEnabledCodexHooksFeature(configContent) && !configContent.includes('gsd-update-check')) {
if (hasEnabledCodexHooksFeature(configContent) && !configContent.includes('gsd-check-update')) {
configContent += hookBlock;
}