UNPKG

cmte

Version:

Design by Committee™ except it's just you and LLMs

217 lines (197 loc) 8.74 kB
import { glob } from 'glob'; import { minimatch } from 'minimatch'; import path from 'path'; import fs from 'fs/promises'; import { logger } from '../utils/logger.js'; /** * Manages named groups (collections) of files identified by paths or glob patterns. */ export class FileCollectionManager { constructor(basePath, variableResolver = null) { if (!basePath) throw new Error('FileCollectionManager requires a basePath.'); this.basePath = path.resolve(basePath); this.collections = new Map(); this.definitions = new Map(); this.variableResolver = variableResolver; logger.info('FileCollectionManager initialized', { basePath: this.basePath }); } /** * Clears all registered collections. */ clearAllCollections() { this.collections.clear(); this.definitions.clear(); logger.info('Cleared all file collections.'); } /** * Registers a collection based on include/exclude definitions. * Resolves globs immediately and stores the resulting file list. * @param {string} name - Name of the collection * @param {object} definition - Object with optional include and exclude arrays of patterns/paths. * @throws {Error} If definition is invalid. */ async registerCollection(name, definition) { logger.debug(`Registering file collection: ${name}`, { definition }); if (!definition || (typeof definition !== 'object')) { throw new Error(`Invalid definition for file collection '${name}'. Must be an object.`); } const includePatterns = definition.include || []; const excludePatterns = definition.exclude || []; if (!Array.isArray(includePatterns) || !Array.isArray(excludePatterns)) { throw new Error(`Invalid definition for file collection '${name}'. 'include' and 'exclude' must be arrays.`); } // Store the definition for potential future reference (e.g., re-globbing?) this.definitions.set(name, { include: includePatterns, exclude: excludePatterns }); // Find files based on include patterns let includedFiles = new Set(); for (const pattern of includePatterns) { // Glob patterns relative to the basePath const matches = await glob(pattern, { cwd: this.basePath, nodir: true, absolute: true }); matches.forEach(match => includedFiles.add(path.normalize(match))); } // Filter out excluded files let finalFiles = Array.from(includedFiles); if (excludePatterns.length > 0) { finalFiles = finalFiles.filter(file => { // Check against all exclude patterns // Use relative path for exclusion matching if patterns are relative const relativeFile = path.relative(this.basePath, file); for (const pattern of excludePatterns) { if (minimatch(relativeFile, pattern)) { return false; // Exclude if any pattern matches } } return true; // Keep if no exclude patterns match }); } // Store the resolved, normalized, relative paths const relativePaths = finalFiles.map(file => path.relative(this.basePath, file)); this.collections.set(name, relativePaths); logger.info(`Registered collection '${name}' with ${relativePaths.length} files.`); logger.debug(`Files for collection '${name}':`, { files: relativePaths.slice(0, 10) }); // Log first 10 } /** * Gets the list of resolved, relative file paths for a registered collection. * Optionally filters this list further using minimatch. * @param {string} name - Name of the collection * @param {string|null} filterPattern - Optional glob pattern to filter the collection's files * @param {Object} context - Optional context for resolving variables in pattern (Not currently used here) * @returns {string[]} Array of matching relative file paths * @throws {Error} If collection doesn't exist */ getFiles(name, filterPattern = null, context = {}) { if (!this.hasCollection(name)) { throw new Error(`File collection not found: ${name}`); } const files = this.collections.get(name); if (!filterPattern) { return [...files]; } // Apply glob pattern filtering on the relative paths return files.filter(file => minimatch(file, filterPattern)); } /** * Check if a collection exists * @param {string} name - Name of the collection * @returns {boolean} True if collection exists */ hasCollection(name) { return this.collections.has(name); } /** * Get all collection names * @returns {string[]} Array of collection names */ getCollectionNames() { return Array.from(this.collections.keys()); } /** * Gets the absolute path for a file known to be in a collection. * Used internally for reading file content. * @param {string} file - Relative file path (as stored in the collection) * @returns {string} Absolute file path * @throws {Error} If the relative path somehow doesn't resolve correctly (shouldn't happen) */ _getAbsolutePath(file) { const absolutePath = path.resolve(this.basePath, file); // Basic sanity check // --- Removing this restriction per user request --- // if (!absolutePath.startsWith(this.basePath)) { // logger.error('Resolved path is outside base path', { file, absolutePath, basePath: this.basePath }); // throw new Error(`Security error: Resolved path '${absolutePath}' is outside base path '${this.basePath}'.`); // } // --- End removal --- return absolutePath; } /** * Reads the content of a file from a registered collection. * @param {string} name - Name of the collection * @param {string} file - Relative file path (must be one returned by getFiles) * @returns {Promise<string>} File contents * @throws {Error} If collection or file is not found or readable. */ async readFileFromCollection(name, file) { if (!this.hasCollection(name)) { throw new Error(`File collection not found: ${name}`); } const files = this.collections.get(name); const normalizedFile = path.normalize(file); if (!files.includes(normalizedFile)) { throw new Error(`File '${file}' not found in collection '${name}'. Available: ${files.join(', ')}`); } const absolutePath = this._getAbsolutePath(normalizedFile); logger.debug(`Reading file from collection '${name}': ${file}`, { absolutePath }); return fs.readFile(absolutePath, 'utf8'); } /** * Finds files matching a glob pattern relative to the base path * and returns their relative paths. Does not use registered collections. * @param {string} pattern - Glob pattern * @returns {Promise<string[]>} Array of matching relative file paths */ async findFiles(pattern) { logger.debug('Finding files for pattern', { pattern, basePath: this.basePath }); const matches = await glob(pattern, { cwd: this.basePath, nodir: true, absolute: true }); const relativePaths = matches.map(match => path.relative(this.basePath, path.normalize(match))); logger.debug(`Found ${relativePaths.length} files for pattern '${pattern}'`); return relativePaths; } /** * Reads the content of a file specified by a path relative to the base path. * Does not use registered collections. * @param {string} relativePath - Relative file path * @returns {Promise<string>} File contents * @throws {Error} If file is not found or readable. */ async readFileRelative(relativePath) { const normalizedPath = path.normalize(relativePath); // Basic check to prevent reading outside base path via relative paths like ../../etc/passwd // --- Removing this restriction per user request --- // if (normalizedPath.startsWith('..') || path.isAbsolute(normalizedPath)) { // throw new Error(`Invalid relative path: ${relativePath}. Must be within base path.`); // } // --- End removal --- const absolutePath = this._getAbsolutePath(normalizedPath); // This still has a safety check logger.debug('Reading relative file', { relativePath, absolutePath }); return fs.readFile(absolutePath, 'utf8'); } /** * Resolve variables in a pattern string using the variable resolver * @private * @param {string} pattern - Pattern string that may contain variables * @param {Object} context - Context for variable resolution * @returns {string} Pattern with variables resolved */ resolveVariablesInPattern(pattern, context) { if (!this.variableResolver) { return pattern; } return pattern.replace(/\{\{([^}]+)\}\}/g, (match, reference) => { try { return this.variableResolver.resolve(reference, context); } catch (error) { return match; // Keep original if resolution fails } }); } }