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