#!/usr/bin/env node /** * Hook Installer * * Installs git hooks and Claude hooks for TDD enforcement */ const fs = require('fs'); const path = require('path'); class HookInstaller { constructor(projectRoot, skillRoot) { this.projectRoot = projectRoot; this.skillRoot = skillRoot; this.gitHooksDir = path.join(projectRoot, '.git', 'hooks'); this.claudeHooksDir = path.join(projectRoot, '.claude', 'hooks'); this.tddAutomationDir = path.join(projectRoot, '.tdd-automation'); } /** * Install all TDD hooks * @returns {object} Installation result */ installAll() { const results = { success: true, installed: [], failed: [], skipped: [] }; // Create directories this.ensureDirectories(); // Copy TDD automation files this.copyTddAutomationFiles(results); // Install git pre-commit hook this.installGitPreCommit(results); // Install Claude hooks (if Claude hooks directory exists) if (fs.existsSync(path.dirname(this.claudeHooksDir))) { this.installClaudeHooks(results); } results.success = results.failed.length === 0; return results; } /** * Ensure required directories exist */ ensureDirectories() { const dirs = [ this.tddAutomationDir, path.join(this.tddAutomationDir, 'scripts'), path.join(this.tddAutomationDir, 'templates'), path.join(this.tddAutomationDir, 'hooks') ]; for (const dir of dirs) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } // Create Claude hooks directory if .claude exists if (fs.existsSync(path.join(this.projectRoot, '.claude'))) { if (!fs.existsSync(this.claudeHooksDir)) { fs.mkdirSync(this.claudeHooksDir, { recursive: true }); } } } /** * Copy TDD automation files to project * @param {object} results - Results object to update */ copyTddAutomationFiles(results) { const scriptsDir = path.join(this.skillRoot, 'scripts'); const templatesDir = path.join(this.skillRoot, 'templates'); const targetScriptsDir = path.join(this.tddAutomationDir, 'scripts'); const targetTemplatesDir = path.join(this.tddAutomationDir, 'templates'); try { // Copy scripts if (fs.existsSync(scriptsDir)) { this.copyDirectory(scriptsDir, targetScriptsDir); results.installed.push('TDD automation scripts'); } // Copy templates if (fs.existsSync(templatesDir)) { this.copyDirectory(templatesDir, targetTemplatesDir); results.installed.push('TDD templates'); } } catch (error) { results.failed.push(`Copy TDD files: ${error.message}`); } } /** * Install git pre-commit hook * @param {object} results - Results object to update */ installGitPreCommit(results) { if (!fs.existsSync(this.gitHooksDir)) { results.skipped.push('Git pre-commit hook (no .git directory)'); return; } const hookPath = path.join(this.gitHooksDir, 'pre-commit'); const templatePath = path.join(this.skillRoot, 'templates', 'pre-commit.sh'); try { // Check if hook already exists if (fs.existsSync(hookPath)) { const existing = fs.readFileSync(hookPath, 'utf-8'); // Check if our TDD hook is already installed if (existing.includes('TDD_AUTOMATION')) { results.skipped.push('Git pre-commit hook (already installed)'); return; } // Backup existing hook const backupPath = hookPath + '.backup'; fs.copyFileSync(hookPath, backupPath); results.installed.push(`Git pre-commit hook backup (${backupPath})`); // Append our hook to existing const tddHook = fs.readFileSync(templatePath, 'utf-8'); fs.appendFileSync(hookPath, '\n\n' + tddHook); results.installed.push('Git pre-commit hook (appended)'); } else { // Install new hook fs.copyFileSync(templatePath, hookPath); fs.chmodSync(hookPath, '755'); results.installed.push('Git pre-commit hook (new)'); } } catch (error) { results.failed.push(`Git pre-commit hook: ${error.message}`); } } /** * Install Claude hooks * @param {object} results - Results object to update */ installClaudeHooks(results) { const hooksToInstall = [ { name: 'tdd-auto-enforcer.sh', description: 'TDD auto-enforcer hook' } ]; for (const hook of hooksToInstall) { const sourcePath = path.join(this.skillRoot, 'templates', hook.name); const targetPath = path.join(this.claudeHooksDir, hook.name); try { if (fs.existsSync(targetPath)) { results.skipped.push(`${hook.description} (already exists)`); continue; } if (!fs.existsSync(sourcePath)) { results.failed.push(`${hook.description} (template not found)`); continue; } fs.copyFileSync(sourcePath, targetPath); fs.chmodSync(targetPath, '755'); results.installed.push(hook.description); } catch (error) { results.failed.push(`${hook.description}: ${error.message}`); } } } /** * Copy directory recursively * @param {string} src - Source directory * @param {string} dest - Destination directory */ copyDirectory(src, dest) { if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); } const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { this.copyDirectory(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); // Make scripts executable if (entry.name.endsWith('.sh') || entry.name.endsWith('.js')) { try { fs.chmodSync(destPath, '755'); } catch (error) { // Ignore chmod errors on systems that don't support it } } } } } /** * Uninstall all TDD hooks * @returns {object} Uninstallation result */ uninstallAll() { const results = { success: true, removed: [], failed: [] }; // Remove git pre-commit hook (only our section) this.uninstallGitPreCommit(results); // Remove Claude hooks this.uninstallClaudeHooks(results); // Remove TDD automation directory if (fs.existsSync(this.tddAutomationDir)) { try { fs.rmSync(this.tddAutomationDir, { recursive: true, force: true }); results.removed.push('.tdd-automation directory'); } catch (error) { results.failed.push(`.tdd-automation directory: ${error.message}`); } } results.success = results.failed.length === 0; return results; } /** * Uninstall git pre-commit hook * @param {object} results - Results object to update */ uninstallGitPreCommit(results) { const hookPath = path.join(this.gitHooksDir, 'pre-commit'); if (!fs.existsSync(hookPath)) { return; } try { const content = fs.readFileSync(hookPath, 'utf-8'); // Check if our hook is installed if (!content.includes('TDD_AUTOMATION')) { return; } // If the entire file is our hook, remove it if (content.trim().startsWith('#!/bin/bash') && content.includes('TDD_AUTOMATION')) { // Check if there's a backup const backupPath = hookPath + '.backup'; if (fs.existsSync(backupPath)) { fs.copyFileSync(backupPath, hookPath); results.removed.push('Git pre-commit hook (restored from backup)'); } else { fs.unlinkSync(hookPath); results.removed.push('Git pre-commit hook (removed)'); } } } catch (error) { results.failed.push(`Git pre-commit hook: ${error.message}`); } } /** * Uninstall Claude hooks * @param {object} results - Results object to update */ uninstallClaudeHooks(results) { const hooksToRemove = ['tdd-auto-enforcer.sh']; for (const hookName of hooksToRemove) { const hookPath = path.join(this.claudeHooksDir, hookName); if (fs.existsSync(hookPath)) { try { fs.unlinkSync(hookPath); results.removed.push(`Claude hook: ${hookName}`); } catch (error) { results.failed.push(`Claude hook ${hookName}: ${error.message}`); } } } } } module.exports = HookInstaller;