cmte
Version:
Design by Committee™ except it's just you and LLMs
217 lines (197 loc) • 8.74 kB
JavaScript
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
}
});
}
}