UNPKG

@cirrusinvicta/ai-commit-toolkit

Version:

AI-powered conventional commit generation with centralized configuration, OpenAI integration, and automated deployment workflows

697 lines (597 loc) 22.6 kB
const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); class AICommitToolkitSetup { constructor(options = {}) { this.options = options; this.projectRoot = process.cwd(); this.templateDir = path.join(__dirname, "..", "templates"); // Setup options for handling updates this.forceOverwrite = options.force || false; this.createBackups = options.backup || false; this.skipExisting = options.skipExisting !== false; // Default to true } async install(projectOptions = {}) { console.log("🤖 Setting up AI Commit Toolkit..."); const config = await this.gatherConfiguration(projectOptions); try { await this.checkPrerequisites(); await this.installDependencies(config); await this.setupHusky(config); await this.setupCommitlint(config); await this.setupSemanticRelease(config); await this.copyScripts(config); await this.createConfiguration(config); await this.updatePackageJson(config); // Ensure .gitignore exists and is correct const gitignorePath = path.join(this.projectRoot, ".gitignore"); // Read exclusions from ai-prompts.conf if available (after it's been copied) const aiPromptsConfPath = path.join( this.projectRoot, "config", "ai-prompts.conf" ); let gitignoreExclusions = ["node_modules", ".env"]; if (fs.existsSync(aiPromptsConfPath)) { const conf = fs.readFileSync(aiPromptsConfPath, "utf8"); const match = conf.match(/^GITIGNORE_EXCLUDE="([^"]*)"/m); if (match?.[1]) { gitignoreExclusions = match[1].split(/\s+/).filter(Boolean); } } if (!fs.existsSync(gitignorePath)) { fs.writeFileSync(gitignorePath, gitignoreExclusions.join("\n") + "\n"); console.log( ` ✅ Created .gitignore with exclusions: ${gitignoreExclusions.join( ", " )}` ); } await this.ensureGitignore(gitignoreExclusions); console.log("✅ AI Commit Toolkit setup complete!"); console.log( "💡 Set OPENAI_API_KEY environment variable to enable AI features" ); console.log("🚀 Try: git add . && git commit"); return true; } catch (error) { console.error("❌ Setup failed:", error.message); console.error(error.stack); return false; } } async gatherConfiguration(projectOptions) { // Validate commit style option const validStyles = ['user-centric', 'developer-centric']; const commitStyle = projectOptions.commitStyle || 'user-centric'; if (!validStyles.includes(commitStyle)) { console.log(`⚠️ Invalid commit style "${commitStyle}". Using default "user-centric".`); commitStyle = 'user-centric'; } // Simple configuration for now - can be enhanced with inquirer later return { type: projectOptions.type || "generic", commitStyle: commitStyle, setupHooks: projectOptions.hooks !== false, setupSemanticRelease: projectOptions.semanticRelease !== false, silent: projectOptions.silent || false, ...projectOptions, }; } async checkPrerequisites() { console.log("🔍 Checking prerequisites..."); // First check if we're in a git repository try { execSync("git rev-parse --git-dir", { stdio: "ignore", cwd: this.projectRoot, }); console.log(" ✅ Git repository detected"); } catch { throw new Error( `❌ Not in a git repository. Please run 'git init' first or navigate to an existing git repository.` ); } const required = ["git", "jq", "curl", "bc", "node", "npm"]; for (const dep of required) { try { execSync(`which ${dep}`, { stdio: "ignore" }); console.log(` ✅ ${dep} found`); } catch { throw new Error( `Missing required dependency: ${dep}. Please install it first.` ); } } } async installDependencies(config) { console.log("📦 Installing dependencies..."); const packagePath = path.join(this.projectRoot, "package.json"); if (!fs.existsSync(packagePath)) { console.log(" ⚠️ No package.json found, creating basic one..."); await this.createBasicPackageJson(); } // Install required dependencies const deps = [ "husky@^9.0.0", "@commitlint/cli@^19.0.0", "@commitlint/config-conventional@^19.0.0", ]; if (config.setupSemanticRelease) { deps.push( "semantic-release@^24.0.0", "@semantic-release/changelog@^6.0.0", "@semantic-release/git@^10.0.0" ); } try { execSync(`npm install --save-dev ${deps.join(" ")}`, { stdio: "inherit", cwd: this.projectRoot, }); console.log(" ✅ Dependencies installed"); } catch (error) { throw new Error(`Failed to install dependencies: ${error.message}`); } } async setupHusky(config) { if (!config.setupHooks) { console.log("⏭️ Skipping Husky setup"); return; } console.log("🪝 Setting up Husky hooks..."); try { // Initialize husky (modern way for v9+) execSync("npx husky init", { stdio: "inherit", cwd: this.projectRoot }); // Copy our enhanced hooks await this.copyTemplate( "hooks/prepare-commit-msg", ".husky/prepare-commit-msg" ); await this.copyTemplate("hooks/commit-msg", ".husky/commit-msg"); // Make executable execSync("chmod +x .husky/prepare-commit-msg .husky/commit-msg", { cwd: this.projectRoot, }); console.log(" ✅ Husky hooks configured"); } catch (error) { throw new Error(`Failed to setup Husky: ${error.message}`); } } async setupCommitlint(config) { console.log("📝 Setting up commitlint..."); try { const commitlintConfigPath = path.join( this.projectRoot, "commitlint.config.js" ); // Check if commitlint config already exists if (fs.existsSync(commitlintConfigPath)) { console.log( " 📋 Existing commitlint.config.js found - preserving project configuration" ); console.log( " 💡 AI prompts will be adapted to match your existing commitlint rules" ); return; // Don't overwrite existing commitlint config } // Only create commitlint config if it doesn't exist await this.copyTemplate( "config/commitlint.config.js", "commitlint.config.js" ); console.log(" ✅ Commitlint configured"); } catch (error) { throw new Error(`Failed to setup commitlint: ${error.message}`); } } async readExistingCommitlintConfig() { const commitlintConfigPath = path.join( this.projectRoot, "commitlint.config.js" ); if (!fs.existsSync(commitlintConfigPath)) { return null; } try { // Read the commitlint config file delete require.cache[require.resolve(commitlintConfigPath)]; const commitlintConfig = require(commitlintConfigPath); const extractedRules = { types: [], maxHeaderLength: 72, maxBodyLineLength: 100, }; // Extract type-enum rule if (commitlintConfig.rules && commitlintConfig.rules["type-enum"]) { const typeRule = commitlintConfig.rules["type-enum"]; if ( Array.isArray(typeRule) && typeRule.length >= 3 && Array.isArray(typeRule[2]) ) { extractedRules.types = typeRule[2]; } } // Extract header-max-length rule if ( commitlintConfig.rules && commitlintConfig.rules["header-max-length"] ) { const headerRule = commitlintConfig.rules["header-max-length"]; if ( Array.isArray(headerRule) && headerRule.length >= 3 && typeof headerRule[2] === "number" ) { extractedRules.maxHeaderLength = headerRule[2]; } } // Extract body-max-line-length rule if ( commitlintConfig.rules && commitlintConfig.rules["body-max-line-length"] ) { const bodyRule = commitlintConfig.rules["body-max-line-length"]; if ( Array.isArray(bodyRule) && bodyRule.length >= 3 && typeof bodyRule[2] === "number" ) { extractedRules.maxBodyLineLength = bodyRule[2]; } } console.log(" 📊 Detected commitlint rules:"); console.log(` Types: ${extractedRules.types.join(", ")}`); console.log(` Max header length: ${extractedRules.maxHeaderLength}`); console.log( ` Max body line length: ${extractedRules.maxBodyLineLength}` ); return extractedRules; } catch (error) { console.log( " ⚠️ Could not parse existing commitlint config - using defaults" ); return null; } } async setupSemanticRelease(config) { if (!config.setupSemanticRelease) { console.log("⏭️ Skipping semantic-release setup"); return; } console.log("🏷️ Setting up semantic-release..."); try { const templateName = `config/.releaserc-${config.type}.json`; const templateExists = fs.existsSync( path.join(this.templateDir, templateName) ); if (templateExists) { await this.copyTemplate(templateName, ".releaserc.json"); } else { await this.copyTemplate("config/.releaserc.json", ".releaserc.json"); } console.log(" ✅ Semantic-release configured"); } catch (error) { throw new Error(`Failed to setup semantic-release: ${error.message}`); } } async copyScripts(config) { console.log("📜 Installing AI commit scripts..."); try { // Create scripts directory if (!fs.existsSync(path.join(this.projectRoot, "scripts"))) { fs.mkdirSync(path.join(this.projectRoot, "scripts")); } // Create config directory for AI prompts configuration if (!fs.existsSync(path.join(this.projectRoot, "config"))) { fs.mkdirSync(path.join(this.projectRoot, "config")); } const scripts = [ "ai-commit.sh", "commit-helper-ai.sh", "test-ai-setup.sh", "scope-helper.sh", ]; for (const script of scripts) { await this.copyTemplate(`scripts/${script}`, `scripts/${script}`); execSync(`chmod +x scripts/${script}`, { cwd: this.projectRoot }); } // Create AI prompts configuration with adaptation to existing commitlint rules const configPath = path.join(this.projectRoot, "config/ai-prompts.conf"); const configExists = fs.existsSync(configPath); if (!configExists) { // Check for existing commitlint rules to adapt configuration const existingCommitlintRules = await this.readExistingCommitlintConfig(); await this.createAdaptedConfiguration(existingCommitlintRules, config); } else { // Use existing protection logic for updates await this.copyTemplate( "config/ai-prompts.conf", "config/ai-prompts.conf", { skipIfExists: this.skipExisting && !this.forceOverwrite, backup: this.createBackups, force: this.forceOverwrite, } ); } console.log(" ✅ Scripts installed"); } catch (error) { throw new Error(`Failed to copy scripts: ${error.message}`); } } async createConfiguration(config) { console.log("⚙️ Creating configuration files..."); try { // Create .env if it doesn't exist const envPath = path.join(this.projectRoot, ".env"); if (!fs.existsSync(envPath)) { fs.writeFileSync(envPath, "OPENAI_API_KEY=your-openai-api-key-here\n"); console.log( " ⚠️ Created .env file - remember to add your OpenAI API key" ); } // Check if this is an update scenario for ai-prompts.conf const configPath = path.join(this.projectRoot, "config/ai-prompts.conf"); const isUpdate = fs.existsSync(configPath); if (isUpdate && !this.forceOverwrite) { console.log(" 🔄 Detected existing AI prompts configuration"); await this.handleConfigurationUpdate(configPath); } else { console.log(" 📋 Creating AI prompts configuration..."); // Check for existing commitlint rules to adapt configuration const existingCommitlintRules = await this.readExistingCommitlintConfig(); await this.createAdaptedConfiguration(existingCommitlintRules, config); } console.log(" ✅ Configuration files created"); } catch (error) { throw new Error(`Failed to create configuration: ${error.message}`); } } async createAdaptedConfiguration(commitlintRules, config = {}) { const configPath = path.join(this.projectRoot, "config/ai-prompts.conf"); if (commitlintRules) { console.log( " 🎯 Adapting AI configuration to match existing commitlint rules" ); // Read the template configuration const templatePath = path.join( this.templateDir, "config/ai-prompts.conf" ); let templateContent = fs.readFileSync(templatePath, "utf8"); // Adapt the configuration based on existing commitlint rules if (commitlintRules.types.length > 0) { const typesString = commitlintRules.types.join(","); templateContent = templateContent.replace( /VALID_COMMIT_TYPES="[^"]*"/, `VALID_COMMIT_TYPES="${typesString}"` ); } if (commitlintRules.maxHeaderLength !== 72) { templateContent = templateContent.replace( /MAX_COMMIT_LENGTH=\d+/, `MAX_COMMIT_LENGTH=${commitlintRules.maxHeaderLength}` ); // Also update the warn length to be slightly less than max const warnLength = Math.max(50, commitlintRules.maxHeaderLength - 10); templateContent = templateContent.replace( /WARN_COMMIT_LENGTH=\d+/, `WARN_COMMIT_LENGTH=${warnLength}` ); } // Set the commit message style based on user preference if (config.commitStyle) { templateContent = templateContent.replace( /COMMIT_MESSAGE_STYLE="[^"]*"/, `COMMIT_MESSAGE_STYLE="${config.commitStyle}"` ); console.log(` 🎨 Set commit message style to "${config.commitStyle}"`); } // Write the adapted configuration fs.writeFileSync(configPath, templateContent); console.log(" ✅ AI configuration adapted to existing commitlint rules"); // Provide suggestions if there are significant differences if (commitlintRules.maxHeaderLength < 60) { console.log( " 💡 Note: Your commitlint has a short header limit (" + commitlintRules.maxHeaderLength + " chars)" ); console.log( " Consider increasing it for better AI-generated commit messages" ); } } else { // No existing commitlint config, use default template await this.copyTemplate( "config/ai-prompts.conf", "config/ai-prompts.conf", { skipIfExists: this.skipExisting && !this.forceOverwrite, backup: this.createBackups, force: this.forceOverwrite, } ); // Update the style if specified and not the default if (config.commitStyle && config.commitStyle !== 'user-centric') { const templateContent = fs.readFileSync(configPath, "utf8"); const updatedContent = templateContent.replace( /COMMIT_MESSAGE_STYLE="[^"]*"/, `COMMIT_MESSAGE_STYLE="${config.commitStyle}"` ); fs.writeFileSync(configPath, updatedContent); console.log(` 🎨 Set commit message style to "${config.commitStyle}"`); } console.log(" ✅ Default AI configuration created"); } } async handleConfigurationUpdate(existingConfigPath) { console.log(" 📋 Checking configuration for updates..."); try { // Read existing config const existingConfig = fs.readFileSync(existingConfigPath, "utf8"); // Read template config const templatePath = path.join( this.templateDir, "config/ai-prompts.conf" ); const templateConfig = fs.readFileSync(templatePath, "utf8"); // Check for new variables in template const templateLines = templateConfig.split("\n"); const existingLines = existingConfig.split("\n"); const newVariables = []; for (const line of templateLines) { if (line.trim() && line.includes("=") && !line.startsWith("#")) { const varName = line.split("=")[0].trim(); const hasVariable = existingLines.some((existingLine) => existingLine.trim().startsWith(varName + "=") ); if (!hasVariable) { newVariables.push(line); } } } if (newVariables.length > 0) { console.log( ` 📋 Found ${newVariables.length} new configuration option(s)` ); if (this.createBackups) { const backupPath = `${existingConfigPath}.backup.${Date.now()}`; fs.copyFileSync(existingConfigPath, backupPath); console.log(` 📋 Backup created: ${path.basename(backupPath)}`); } // Append new variables to existing config const updatedConfig = existingConfig + "\n# New options added by toolkit update:\n" + newVariables.join("\n") + "\n"; fs.writeFileSync(existingConfigPath, updatedConfig); console.log(" ✅ Configuration updated with new options"); console.log(" 💡 Review config/ai-prompts.conf for new settings"); } else { console.log(" ✅ Configuration is up to date"); } } catch (error) { console.log( ` ⚠️ Could not check configuration updates: ${error.message}` ); } } async updatePackageJson(config) { console.log("📄 Updating package.json..."); try { const packagePath = path.join(this.projectRoot, "package.json"); const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); // Add our scripts pkg.scripts = pkg.scripts || {}; pkg.scripts["ai-commit"] = "ai-commit"; pkg.scripts["ai-commit-helper"] = "ai-commit-helper"; pkg.scripts["ai-commit-scopes"] = "bash scripts/scope-helper.sh"; pkg.scripts["test-ai"] = "ai-commit-test"; pkg.scripts["prepare"] = "husky"; // Modern Husky v9+ syntax (not 'husky install') fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2)); console.log(" ✅ package.json updated"); } catch (error) { throw new Error(`Failed to update package.json: ${error.message}`); } } async createBasicPackageJson() { const basicPackage = { name: path.basename(this.projectRoot), version: "1.0.0", description: "", main: "index.js", scripts: { test: 'echo "Error: no test specified" && exit 1', }, keywords: [], author: "", license: "ISC", }; fs.writeFileSync( path.join(this.projectRoot, "package.json"), JSON.stringify(basicPackage, null, 2) ); } async copyTemplate(src, dest, options = {}) { const srcPath = path.join(this.templateDir, src); const destPath = path.join(this.projectRoot, dest); if (fs.existsSync(srcPath)) { const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // Protection against overwriting user customizations if (fs.existsSync(destPath)) { // Create backup if requested (regardless of other options) if (options.backup) { const backupPath = `${destPath}.backup.${Date.now()}`; fs.copyFileSync(destPath, backupPath); console.log(` 📋 Backup created: ${path.basename(backupPath)}`); } // If force is not set, check if we should skip if (!options.force) { if (options.skipIfExists) { console.log(` ⏭️ Skipped (exists): ${dest}`); return; } // For config files, offer special handling if (dest.includes("ai-prompts.conf")) { console.log(` ⚠️ Config file exists: ${dest}`); console.log( ` Use --force to overwrite or --backup to backup first` ); console.log( ` Keeping existing configuration to preserve customizations` ); return; } } // If we get here with force=true, we will overwrite (backup already created if requested) if (options.force) { console.log(` 🔄 Overwriting: ${dest}`); } } fs.copyFileSync(srcPath, destPath); } else { console.warn(` ⚠️ Template not found: ${src}`); } } async ensureGitignore(exclusions = ["node_modules", ".env"]) { const gitignorePath = path.join(this.projectRoot, ".gitignore"); let lines = []; if (fs.existsSync(gitignorePath)) { lines = fs.readFileSync(gitignorePath, "utf8").split(/\r?\n/); } let changed = false; exclusions.forEach((entry) => { // Smart duplicate detection - check for both 'node_modules' and 'node_modules/' const normalizedEntry = entry.replace(/\/$/, ""); // Remove trailing slash for comparison const hasExactMatch = lines.some((line) => line.trim() === entry); const hasVariantMatch = lines.some((line) => { const normalizedLine = line.trim().replace(/\/$/, ""); return normalizedLine === normalizedEntry; }); if (!hasExactMatch && !hasVariantMatch) { lines.push(entry); changed = true; } }); if (changed) { // Filter out empty lines and add clean ending const cleanLines = lines.filter((line) => line.trim() !== ""); fs.writeFileSync(gitignorePath, cleanLines.join("\n") + "\n"); console.log( ` ✅ Updated .gitignore to include: ${exclusions.join(", ")}` ); } else { console.log(" ✅ .gitignore already includes all exclusions"); } } } module.exports = AICommitToolkitSetup;