@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
JavaScript
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;