UNPKG

codesummary

Version:

Cross-platform CLI tool that generates professional PDF documentation and RAG-optimized JSON outputs from project source code. Perfect for code reviews, audits, documentation, and AI/ML applications with semantic chunking and precision offsets.

828 lines (744 loc) • 24.9 kB
import chalk from "chalk"; import fs from "fs-extra"; import inquirer from "inquirer"; import os from "os"; import path from "path"; import ErrorHandler from "./errorHandler.js"; /** * Configuration Manager for CodeSummary * Handles global configuration storage, first-run setup, and user preferences * Cross-platform compatible with POSIX and Windows systems */ export class ConfigManager { constructor() { this.configDir = this.getConfigDirectory(); this.configPath = path.join(this.configDir, "config.json"); this.defaultConfig = this.getDefaultConfig(); } /** * Get the appropriate configuration directory based on platform * @returns {string} Configuration directory path */ getConfigDirectory() { const platform = os.platform(); const homeDir = os.homedir(); if (platform === "win32") { // Windows: %APPDATA%\CodeSummary\ return path.join(process.env.APPDATA || homeDir, "CodeSummary"); } else { // POSIX (Linux/macOS): ~/.codesummary/ return path.join(homeDir, ".codesummary"); } } /** * Get default configuration object * @returns {object} Default configuration */ getDefaultConfig() { return { configVersion: "1.1.0", // Version tracking for migrations output: { mode: "fixed", fixedPath: path.join(os.homedir(), "Desktop", "CodeSummaries"), }, allowedExtensions: [ ".json", ".ts", ".js", ".jsx", ".tsx", ".xml", ".html", ".css", ".scss", ".md", ".txt", ".py", ".java", ".cs", ".cpp", ".c", ".h", ".yaml", ".yml", ".sh", ".bat", ], excludeDirs: [ "node_modules", ".git", ".vscode", "dist", "build", "coverage", "out", "__pycache__", ".next", ".nuxt", ], excludeFiles: [ "*-lock.json", // package-lock.json, yarn.lock, etc. "*.lock", // Cargo.lock, Gemfile.lock, etc. "composer.lock", // PHP Composer lock "Pipfile.lock", // Python Pipfile lock "*.min.js", // Minified JavaScript "*.min.css", // Minified CSS "*.map", // Source maps ".DS_Store", // macOS metadata "Thumbs.db", // Windows thumbnail cache "*-lock.yaml", // YAML lock files ], styles: { colors: { title: "#333353", section: "#00FFB9", text: "#333333", error: "#FF4D4D", footer: "#666666", }, layout: { marginLeft: 40, marginTop: 40, marginRight: 40, footerHeight: 20, }, fonts: { base: "Source Code Pro", }, }, settings: { documentTitle: "Project Code Summary", maxFilesBeforePrompt: 500, }, }; } /** * Check if configuration file exists * @returns {boolean} True if config exists */ configExists() { return fs.existsSync(this.configPath); } /** * Load configuration from file or return null if not found/corrupted * @returns {object|null} Configuration object or null */ async loadConfig() { try { if (!this.configExists()) { return null; } const configData = await fs.readJSON(this.configPath); // Migrate configuration if needed (add missing fields) const migratedConfig = this.migrateConfig(configData); // Validate configuration structure ErrorHandler.validateConfig(migratedConfig); // Save migrated config back if it was changed if (JSON.stringify(configData) !== JSON.stringify(migratedConfig)) { await this.saveConfig(migratedConfig); // Show user-friendly notification about what was updated if (this._pendingNotification) { await this.notifyConfigUpdates( this._pendingNotification.newExclusions, this._pendingNotification.newDirs, this._pendingNotification.newExtensions ); this._pendingNotification = null; } } return migratedConfig; } catch (error) { if (error.code === "ENOENT") { return null; } if (error.message.includes("JSON")) { console.log( chalk.red("ERROR: Configuration file contains invalid JSON.") ); } else if (error.message.includes("Configuration")) { console.log( chalk.red("ERROR: Configuration file structure is invalid.") ); console.log(chalk.gray("Details:"), error.message); } else { ErrorHandler.handleFileSystemError( error, "read configuration", this.configPath ); return null; } const { shouldReset } = await inquirer.prompt([ { type: "confirm", name: "shouldReset", message: "Do you want to reset the configuration?", default: true, }, ]); if (shouldReset) { await this.resetConfig(); return await this.runFirstTimeSetup(); } else { await ErrorHandler.safeExit(1, "Configuration setup cancelled"); } } } /** * Save configuration to file * @param {object} config Configuration object to save */ async saveConfig(config) { try { // Validate configuration before saving ErrorHandler.validateConfig(config); // Ensure config directory exists await fs.ensureDir(this.configDir); // Save configuration with pretty formatting await fs.writeJSON(this.configPath, config, { spaces: 2 }); console.log(chalk.green(`Configuration saved to ${this.configPath}`)); } catch (error) { if (error.message.includes("Configuration")) { console.error( chalk.red("ERROR: Invalid configuration:"), error.message ); } else { ErrorHandler.handleFileSystemError( error, "save configuration", this.configPath ); } await ErrorHandler.safeExit(1, "Configuration save failed"); } } /** * Smart merge arrays preserving user customizations while adding new defaults * @param {Array} userArray - User's current array * @param {Array} defaultArray - New default array * @returns {object} Object with merged array and new items */ smartMergeArrays(userArray, defaultArray) { if (!Array.isArray(userArray)) userArray = []; if (!Array.isArray(defaultArray)) defaultArray = []; // Find new items that user doesn't have const newItems = defaultArray.filter(item => !userArray.includes(item)); // Combine arrays removing duplicates, preserving user's order first const merged = [...userArray, ...newItems]; return { merged, newItems }; } /** * Show user-friendly notification about configuration updates * @param {Array} newExclusions - New exclusion patterns added * @param {Array} newDirs - New directories added * @param {Array} newExtensions - New extensions added */ async notifyConfigUpdates(newExclusions = [], newDirs = [], newExtensions = []) { if (newExclusions.length === 0 && newDirs.length === 0 && newExtensions.length === 0) { return; } console.log(chalk.cyan("\nšŸ“‹ Configuration Updated")); console.log(chalk.gray("New items have been added to improve file scanning:\n")); if (newExclusions.length > 0) { console.log(chalk.green("āœ“ New file patterns to skip:")); newExclusions.forEach(pattern => { console.log(chalk.gray(` • ${pattern} (saves processing time)`)); }); console.log(); } if (newDirs.length > 0) { console.log(chalk.green("āœ“ New directories to skip:")); newDirs.forEach(dir => { console.log(chalk.gray(` • ${dir}/ (improves performance)`)); }); console.log(); } if (newExtensions.length > 0) { console.log(chalk.green("āœ“ New file types to include:")); newExtensions.forEach(ext => { console.log(chalk.gray(` • *${ext} files`)); }); console.log(); } console.log(chalk.gray("Your custom settings have been preserved! ✨\n")); } /** * Migrate old configuration to new format with intelligent merging * @param {object} oldConfig - Old configuration object * @returns {object} Migrated configuration */ migrateConfig(oldConfig) { const defaultConfig = this.getDefaultConfig(); const migratedConfig = { ...oldConfig }; // Track what's new for user notification let newExclusions = []; let newDirs = []; let newExtensions = []; // Add config version if missing if (!migratedConfig.configVersion) { migratedConfig.configVersion = defaultConfig.configVersion; } // Smart merge for excludeFiles if (migratedConfig.excludeFiles) { const { merged, newItems } = this.smartMergeArrays( migratedConfig.excludeFiles, defaultConfig.excludeFiles ); migratedConfig.excludeFiles = merged; newExclusions = newItems; } else { migratedConfig.excludeFiles = defaultConfig.excludeFiles; newExclusions = defaultConfig.excludeFiles; } // Smart merge for excludeDirs if (migratedConfig.excludeDirs) { const { merged, newItems } = this.smartMergeArrays( migratedConfig.excludeDirs, defaultConfig.excludeDirs ); migratedConfig.excludeDirs = merged; newDirs = newItems; } else { migratedConfig.excludeDirs = defaultConfig.excludeDirs; newDirs = defaultConfig.excludeDirs; } // Smart merge for allowedExtensions if (migratedConfig.allowedExtensions) { const { merged, newItems } = this.smartMergeArrays( migratedConfig.allowedExtensions, defaultConfig.allowedExtensions ); migratedConfig.allowedExtensions = merged; newExtensions = newItems; } else { migratedConfig.allowedExtensions = defaultConfig.allowedExtensions; newExtensions = defaultConfig.allowedExtensions; } // Add any other missing scalar fields from defaults Object.keys(defaultConfig).forEach((key) => { if (!migratedConfig.hasOwnProperty(key) && !Array.isArray(defaultConfig[key])) { migratedConfig[key] = defaultConfig[key]; } }); // Show user-friendly notification about updates (will be called by loadConfig) this._pendingNotification = { newExclusions, newDirs, newExtensions }; return migratedConfig; } /** * Delete existing configuration file */ async resetConfig() { try { if (this.configExists()) { await fs.remove(this.configPath); console.log(chalk.yellow("Configuration reset successfully.")); } } catch (error) { console.error( chalk.red("ERROR: Failed to reset configuration:"), error.message ); } } /** * Run the first-time setup wizard * @returns {object} New configuration object */ async runFirstTimeSetup() { console.log(chalk.cyan("Welcome to CodeSummary!")); console.log(chalk.gray("No configuration found. Starting setup...\n")); const answers = await inquirer.prompt([ { type: "list", name: "outputMode", message: "Where should the PDF be generated by default?", choices: [ { name: "Current working directory (relative mode)", value: "relative", }, { name: "Fixed folder (absolute mode)", value: "fixed", }, ], default: "fixed", }, { type: "input", name: "fixedPath", message: "Enter absolute path for fixed folder:", default: path.join(os.homedir(), "Desktop", "CodeSummaries"), when: (answers) => answers.outputMode === "fixed", validate: (input) => { if (!path.isAbsolute(input)) { return "Please enter an absolute path"; } return true; }, }, ]); // Create the config object const config = { ...this.defaultConfig }; config.output.mode = answers.outputMode; if (answers.outputMode === "fixed") { config.output.fixedPath = path.resolve(answers.fixedPath); // Atomically ensure directory exists try { // Use ensureDir which is atomic and handles race conditions await fs.ensureDir(config.output.fixedPath); // Verify the directory was created and is accessible const stats = await fs.stat(config.output.fixedPath); if (!stats.isDirectory()) { throw new Error("Path exists but is not a directory"); } // Test write permissions const testFile = path.join(config.output.fixedPath, ".write-test"); try { await fs.writeFile(testFile, "test"); await fs.remove(testFile); } catch (writeError) { throw new Error(`Directory not writable: ${writeError.message}`); } console.log( chalk.green( `SUCCESS: Output directory ready: ${config.output.fixedPath}` ) ); } catch (error) { // If directory creation fails, ask user for confirmation console.log( chalk.yellow( `WARNING: Could not prepare output directory: ${error.message}` ) ); const { createDir } = await inquirer.prompt([ { type: "confirm", name: "createDir", message: `Try to create directory ${config.output.fixedPath} anyway?`, default: true, }, ]); if (createDir) { try { // Force creation with more permissive mode await fs.ensureDir(config.output.fixedPath, { mode: 0o755 }); console.log( chalk.green( `SUCCESS: Created directory: ${config.output.fixedPath}` ) ); } catch (retryError) { console.error( chalk.red("ERROR: Failed to create directory:"), retryError.message ); await ErrorHandler.safeExit(1, "Directory creation failed"); } } else { console.log( chalk.yellow( "WARNING: Continuing without creating directory. PDF generation may fail." ) ); } } } // Save the configuration await this.saveConfig(config); console.log(chalk.green("\nSUCCESS: Setup completed successfully!\n")); return config; } /** * Launch interactive configuration editor * @param {object} currentConfig Current configuration to edit * @returns {object} Updated configuration */ async editConfig(currentConfig) { console.log(chalk.cyan("Configuration Editor\n")); const choices = [ "Output Settings", "File Extensions", "Excluded Directories", "Excluded Files", "PDF Styling", "General Settings", "Save and Exit", ]; let config = { ...currentConfig }; let editing = true; while (editing) { const { section } = await inquirer.prompt([ { type: "list", name: "section", message: "Select section to edit:", choices, }, ]); switch (section) { case "Output Settings": config = await this.editOutputSettings(config); break; case "File Extensions": config = await this.editAllowedExtensions(config); break; case "Excluded Directories": config = await this.editExcludedDirs(config); break; case "Excluded Files": config = await this.editExcludedFiles(config); break; case "PDF Styling": console.log( chalk.yellow("PDF styling editor coming in future version") ); break; case "General Settings": config = await this.editGeneralSettings(config); break; case "Save and Exit": editing = false; break; } } await this.saveConfig(config); return config; } /** * Edit output settings * @param {object} config Current configuration * @returns {object} Updated configuration */ async editOutputSettings(config) { const answers = await inquirer.prompt([ { type: "list", name: "mode", message: "Output mode:", choices: [ { name: "Relative (current directory)", value: "relative" }, { name: "Fixed path", value: "fixed" }, ], default: config.output.mode, }, { type: "input", name: "fixedPath", message: "Fixed path:", default: config.output.fixedPath, when: (answers) => answers.mode === "fixed", }, ]); config.output.mode = answers.mode; if (answers.fixedPath) { config.output.fixedPath = path.resolve(answers.fixedPath); } return config; } /** * Edit allowed extensions * @param {object} config Current configuration * @returns {object} Updated configuration */ async editAllowedExtensions(config) { const { extensions } = await inquirer.prompt([ { type: "input", name: "extensions", message: "Allowed extensions (comma-separated):", default: config.allowedExtensions.join(", "), validate: (input) => { if (!input.trim()) { return "Please enter at least one extension"; } return true; }, }, ]); config.allowedExtensions = extensions .split(",") .map((ext) => ext.trim()) .filter((ext) => ext.length > 0) .map((ext) => (ext.startsWith(".") ? ext : "." + ext)); return config; } /** * Edit excluded directories * @param {object} config Current configuration * @returns {object} Updated configuration */ async editExcludedDirs(config) { const { dirs } = await inquirer.prompt([ { type: "input", name: "dirs", message: "Excluded directories (comma-separated):", default: config.excludeDirs.join(", "), }, ]); config.excludeDirs = dirs .split(",") .map((dir) => dir.trim()) .filter((dir) => dir.length > 0); return config; } /** * Edit excluded files patterns * @param {object} config Current configuration * @returns {object} Updated configuration */ async editExcludedFiles(config) { // Ensure excludeFiles exists for backward compatibility if (!config.excludeFiles) { config.excludeFiles = [ "*-lock.json", "*.lock", "composer.lock", "Pipfile.lock", "*.min.js", "*.min.css", "*.map", ".DS_Store", "Thumbs.db", ]; } const { files } = await inquirer.prompt([ { type: "input", name: "files", message: "Excluded file patterns (comma-separated, supports * wildcards):", default: config.excludeFiles.join(", "), validate: (input) => { if (!input.trim()) { return "Enter file patterns or leave empty to clear all exclusions"; } return true; }, }, ]); if (files.trim()) { config.excludeFiles = files .split(",") .map((pattern) => pattern.trim()) .filter((pattern) => pattern.length > 0); } else { config.excludeFiles = []; } console.log( chalk.green( `\nāœ“ Updated excluded files: ${config.excludeFiles.length} patterns` ) ); if (config.excludeFiles.length > 0) { console.log(chalk.gray(" Examples of files that will be excluded:")); config.excludeFiles.slice(0, 3).forEach((pattern) => { console.log(chalk.gray(` - ${pattern}`)); }); if (config.excludeFiles.length > 3) { console.log( chalk.gray(` ... and ${config.excludeFiles.length - 3} more`) ); } } return config; } /** * Edit general settings * @param {object} config Current configuration * @returns {object} Updated configuration */ async editGeneralSettings(config) { const answers = await inquirer.prompt([ { type: "input", name: "documentTitle", message: "Document title:", default: config.settings.documentTitle, }, { type: "number", name: "maxFilesBeforePrompt", message: "Max files before warning prompt:", default: config.settings.maxFilesBeforePrompt, validate: (input) => input > 0 || "Must be a positive number", }, ]); config.settings.documentTitle = answers.documentTitle; config.settings.maxFilesBeforePrompt = answers.maxFilesBeforePrompt; return config; } /** * Display current configuration in user-friendly format * @param {object} config Configuration to display */ displayConfig(config) { console.log(chalk.cyan("\nšŸ“‹ Current Configuration\n")); // Output Settings console.log(chalk.green("šŸ“ Output Settings:")); if (config.output.mode === "fixed") { console.log(chalk.gray(` • Mode: Fixed folder`)); console.log(chalk.gray(` • Location: ${config.output.fixedPath}`)); } else { console.log(chalk.gray(` • Mode: Current directory`)); } console.log(); // File Types console.log(chalk.green("šŸ“„ Included File Types:")); const extensions = config.allowedExtensions || []; if (extensions.length > 0) { // Group by category for better readability const webFiles = extensions.filter(ext => ['.html', '.css', '.scss', '.js', '.jsx', '.ts', '.tsx'].includes(ext)); const configFiles = extensions.filter(ext => ['.json', '.yaml', '.yml', '.xml'].includes(ext)); const docFiles = extensions.filter(ext => ['.md', '.txt'].includes(ext)); const codeFiles = extensions.filter(ext => ![...webFiles, ...configFiles, ...docFiles].includes(ext)); if (webFiles.length > 0) { console.log(chalk.gray(` • Web files: ${webFiles.join(', ')}`)); } if (codeFiles.length > 0) { console.log(chalk.gray(` • Code files: ${codeFiles.join(', ')}`)); } if (configFiles.length > 0) { console.log(chalk.gray(` • Config files: ${configFiles.join(', ')}`)); } if (docFiles.length > 0) { console.log(chalk.gray(` • Documentation: ${docFiles.join(', ')}`)); } } else { console.log(chalk.gray(` • No file types configured`)); } console.log(); // Excluded Directories console.log(chalk.green("🚫 Skipped Directories:")); const excludeDirs = config.excludeDirs || []; if (excludeDirs.length > 0) { excludeDirs.forEach(dir => { console.log(chalk.gray(` • ${dir}/`)); }); } else { console.log(chalk.gray(` • None`)); } console.log(); // Excluded Files console.log(chalk.green("🚫 Skipped File Patterns:")); const excludeFiles = config.excludeFiles || []; if (excludeFiles.length > 0) { excludeFiles.forEach(pattern => { console.log(chalk.gray(` • ${pattern}`)); }); } else { console.log(chalk.gray(` • None`)); } console.log(); // General Settings console.log(chalk.green("āš™ļø General Settings:")); console.log(chalk.gray(` • Document title: "${config.settings?.documentTitle || 'Project Code Summary'}"`)); console.log(chalk.gray(` • File warning threshold: ${config.settings?.maxFilesBeforePrompt || 500} files`)); console.log(chalk.gray(` • Configuration version: ${config.configVersion || 'legacy'}`)); console.log(); } } export default ConfigManager;