Files
gh-awudevelop-claude-plugin…/hooks/pre-deploy-check.js
2025-11-29 17:59:24 +08:00

313 lines
7.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
/**
* PreToolUse Hook: Pre-deployment Safety Checks
*
* Validates deployment operations before execution:
* - Detects deployment-related commands
* - Checks for uncommitted files
* - Validates current branch
* - Blocks execution if unsafe
*/
async function main() {
try {
// Read input from stdin
const input = fs.readFileSync(0, 'utf8').trim();
if (!input) {
process.exit(0); // No input, exit silently
}
const eventData = JSON.parse(input);
// Extract tool input
const toolInput = eventData.tool_input || {};
const cwd = eventData.cwd || process.cwd();
// Change to correct directory
process.chdir(cwd);
// Check if this is a deployment-related command
if (!isDeploymentCommand(toolInput)) {
process.exit(0); // Not a deployment, allow execution
}
// Run safety checks
const issues = await runSafetyChecks(toolInput);
if (issues.length === 0) {
process.exit(0); // All checks passed, allow execution
}
// Checks failed - block execution
const output = {
hookSpecificOutput: {
blockExecution: true,
additionalContext: formatIssues(issues, toolInput)
}
};
console.log(JSON.stringify(output));
process.exit(0);
} catch (error) {
// Silent failure - never block Claude Code
// Log error to stderr for debugging (optional)
process.exit(0);
}
}
/**
* Detect if command is deployment-related
*/
function isDeploymentCommand(toolInput) {
const command = toolInput.command || '';
const description = toolInput.description || '';
// Check for deployment-related keywords
const deploymentKeywords = [
'deploy_dev',
'deploy_uat',
'deploy_prod',
'/deploy',
'deployment'
];
const commandLower = command.toLowerCase();
const descriptionLower = description.toLowerCase();
// Check if command or description contains deployment keywords
for (const keyword of deploymentKeywords) {
if (commandLower.includes(keyword) || descriptionLower.includes(keyword)) {
return true;
}
}
// Check for git operations on deployment branches
if (command.includes('git merge') || command.includes('git push')) {
// Check if targeting a deployment branch
const deployBranchPattern = /deploy_(dev|uat|prod)/;
if (deployBranchPattern.test(command)) {
return true;
}
}
return false;
}
/**
* Extract environment from command if present
*/
function extractEnvironment(toolInput) {
const command = toolInput.command || '';
const description = toolInput.description || '';
const envPattern = /(dev|uat|prod)/i;
// Try to find environment in command
const commandMatch = command.match(envPattern);
if (commandMatch) {
return commandMatch[1].toLowerCase();
}
// Try to find in description
const descMatch = description.match(envPattern);
if (descMatch) {
return descMatch[1].toLowerCase();
}
return null;
}
/**
* Run all safety checks
* Returns array of issues found
*/
async function runSafetyChecks(toolInput) {
const issues = [];
// Load configuration
const configPath = path.join(process.cwd(), '.claude/deployment.config.json');
if (!fs.existsSync(configPath)) {
// No config = not a configured deployment project
// Don't block, user might be doing manual git operations
return [];
}
let config;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (error) {
// Malformed config - warn but don't block
return [{
type: 'warning',
message: 'Deployment config malformed',
fix: 'Check .claude/deployment.config.json syntax'
}];
}
// Extract environment from command
const environment = extractEnvironment(toolInput);
// Check 1: Uncommitted files
if (config.safeguards?.checkUncommittedFiles) {
const uncommitted = checkUncommittedFiles();
if (uncommitted.count > 0) {
issues.push({
type: 'error',
message: `${uncommitted.count} uncommitted file(s) detected`,
details: uncommitted.files.slice(0, 5), // Show first 5 files
fix: 'Commit or stash changes: git add . && git commit -m "..."',
count: uncommitted.count
});
}
}
// Check 2: Branch validation
if (config.safeguards?.checkBranch && environment) {
const branchIssue = checkBranchValidity(config, environment);
if (branchIssue) {
issues.push(branchIssue);
}
}
// Check 3: Environment exists
if (environment && !config.environments[environment]) {
issues.push({
type: 'error',
message: `Unknown environment: ${environment}`,
fix: `Valid environments: ${Object.keys(config.environments).join(', ')}`
});
}
return issues;
}
/**
* Check for uncommitted files
*/
function checkUncommittedFiles() {
try {
const output = execSync('git status --porcelain', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
if (!output) {
return { count: 0, files: [] };
}
const files = output.split('\n').map(line => {
const status = line.substring(0, 2);
const file = line.substring(3);
return { status, file };
});
return { count: files.length, files };
} catch (error) {
// Not a git repo or git command failed
return { count: 0, files: [] };
}
}
/**
* Check if on correct branch for deployment
*/
function checkBranchValidity(config, environment) {
try {
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
const envConfig = config.environments[environment];
if (!envConfig) {
return null; // Environment doesn't exist
}
// Determine expected source branch
let expectedBranch;
if (envConfig.sourceBranch) {
expectedBranch = envConfig.sourceBranch;
} else if (envConfig.sourceEnvironment) {
const sourceEnv = config.environments[envConfig.sourceEnvironment];
expectedBranch = sourceEnv?.branch;
}
if (!expectedBranch) {
return null; // Can't determine expected branch
}
if (currentBranch !== expectedBranch) {
return {
type: 'warning',
message: `On branch "${currentBranch}", expected "${expectedBranch}"`,
fix: `Switch to correct branch: git checkout ${expectedBranch}`
};
}
return null; // Branch is correct
} catch (error) {
return null; // Git command failed, don't block
}
}
/**
* Format issues into user-friendly message
*/
function formatIssues(issues, toolInput) {
const errors = issues.filter(i => i.type === 'error');
const warnings = issues.filter(i => i.type === 'warning');
let message = '🚫 Deployment blocked by safety checks:\n\n';
// Show errors
if (errors.length > 0) {
message += '**Errors** (must fix):\n';
for (const error of errors) {
message += `\n${error.message}\n`;
if (error.details && error.details.length > 0) {
message += ' Files:\n';
for (const detail of error.details) {
message += ` - ${detail.file} (${detail.status})\n`;
}
if (error.count && error.details.length < error.count) {
message += ` ... and ${error.count - error.details.length} more\n`;
}
}
if (error.fix) {
message += ` 💡 Fix: ${error.fix}\n`;
}
}
}
// Show warnings
if (warnings.length > 0) {
message += '\n**Warnings** (recommended to fix):\n';
for (const warning of warnings) {
message += `\n⚠️ ${warning.message}\n`;
if (warning.fix) {
message += ` 💡 Suggestion: ${warning.fix}\n`;
}
}
}
message += '\n---\n\n';
message += 'Fix the issues above before deploying.\n';
message += 'Once fixed, retry the deployment command.\n';
return message;
}
main();