@mseep/mcp-codex-keeper
Version:
An intelligent MCP server that serves as a guardian of development knowledge, providing AI assistants with curated access to latest documentation and best practices
319 lines • 12 kB
JavaScript
import fs from 'fs/promises';
import { createWriteStream } from 'fs';
import path from 'path';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';
/**
* Error thrown when file system operations fail
*/
export class FileSystemError extends Error {
constructor(message, cause) {
super(message);
this.cause = cause;
this.name = 'FileSystemError';
}
}
/**
* Manages file system operations for documentation storage
*/
export class FileSystemManager {
/**
* Creates a new FileSystemManager instance
* @param basePath - Base path for storing documentation
*/
constructor(basePath, cacheConfig = {
maxSize: 100 * 1024 * 1024, // 100MB
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
cleanupInterval: 60 * 60 * 1000, // 1 hour
}) {
// Ensure absolute path and decode URL-encoded characters
this.docsPath = decodeURIComponent(path.resolve(basePath));
this.sourcesFile = path.join(this.docsPath, 'sources.json');
this.cacheDir = path.join(this.docsPath, 'cache');
// Log paths for debugging
console.error('\nFileSystemManager paths:');
console.error('- Base path:', this.docsPath);
console.error('- Sources file:', this.sourcesFile);
console.error('- Cache directory:', this.cacheDir);
console.error('- Current working directory:', process.cwd());
// Use provided cache configuration
this.cacheConfig = cacheConfig;
// Start cache cleanup timer
this.startCleanupTimer();
}
/**
* Starts the cache cleanup timer
*/
startCleanupTimer() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
this.cleanupTimer = setInterval(() => {
this.cleanupCache().catch(console.error);
}, this.cacheConfig.cleanupInterval);
}
/**
* Cleans up old cache files
*/
async cleanupCache() {
try {
const files = await fs.readdir(this.cacheDir);
const now = Date.now();
let totalSize = 0;
// Get file stats and sort by access time
const fileStats = await Promise.all(files.map(async (file) => {
const filePath = path.join(this.cacheDir, file);
const stats = await fs.stat(filePath);
return { file, path: filePath, stats };
}));
fileStats.sort((a, b) => a.stats.atime.getTime() - b.stats.atime.getTime());
// Remove old files and check total size
for (const { file, path: filePath, stats } of fileStats) {
if (stats.mtimeMs < now - this.cacheConfig.maxAge) {
await fs.unlink(filePath);
continue;
}
totalSize += stats.size;
if (totalSize > this.cacheConfig.maxSize) {
await fs.unlink(filePath);
}
}
}
catch (error) {
console.error('Cache cleanup failed:', error);
}
}
/**
* Ensures required directories exist
* @throws {FileSystemError} If directory creation fails
*/
async ensureDirectories() {
try {
console.error('\nEnsuring directories:');
console.error('- Creating docs path:', this.docsPath);
await fs.mkdir(this.docsPath, { recursive: true });
const docsStats = await fs.stat(this.docsPath);
console.error('- Docs path created:', docsStats.isDirectory());
console.error('- Docs path permissions:', docsStats.mode.toString(8));
console.error('- Creating cache dir:', this.cacheDir);
await fs.mkdir(this.cacheDir, { recursive: true });
const cacheStats = await fs.stat(this.cacheDir);
console.error('- Cache dir created:', cacheStats.isDirectory());
console.error('- Cache dir permissions:', cacheStats.mode.toString(8));
// Try to write a test file
const testFile = path.join(this.docsPath, 'test.txt');
console.error('- Testing write permissions:', testFile);
try {
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
console.error('- Write test successful');
}
catch (error) {
// Ignore errors when deleting test file
console.error('- Write test completed with cleanup warning:', error);
}
}
catch (error) {
console.error('Failed to ensure directories:', error);
if (error instanceof Error) {
console.error('Error details:', error.message);
console.error('Stack trace:', error.stack);
}
throw new FileSystemError('Failed to create required directories', error);
}
}
/**
* Saves documentation content to cache using streams
* @param name - Documentation name
* @param content - Content to save
* @throws {FileSystemError} If save operation fails
*/
async saveDocumentation(name, content) {
try {
const filename = this.getDocumentationFileName(name);
await this.ensureDirectories();
// Create a readable stream from the content
const readStream = Readable.from(content);
// Create a write stream to the file
const writeStream = createWriteStream(path.join(this.cacheDir, filename), { flags: 'w' });
// Use pipeline for proper error handling and cleanup
await pipeline(readStream, writeStream);
}
catch (error) {
throw new FileSystemError(`Failed to save documentation: ${name}`, error);
}
}
/**
* Saves documentation sources metadata
* @param docs - Array of documentation sources
* @throws {FileSystemError} If save operation fails
*/
async saveSources(docs) {
try {
console.error('\nSaving sources:');
console.error('- Path:', this.sourcesFile);
console.error('- Docs count:', docs.length);
console.error('- Content:', JSON.stringify(docs, null, 2));
await this.ensureDirectories();
console.error('- Directories ensured');
// Write directly using fs.writeFile
await fs.writeFile(this.sourcesFile, JSON.stringify(docs, null, 2), { mode: 0o666 });
console.error('- File written');
// Verify file was created
const exists = await fs
.access(this.sourcesFile)
.then(() => true)
.catch(() => false);
console.error('- File exists:', exists);
if (exists) {
const stats = await fs.stat(this.sourcesFile);
console.error('- File size:', stats.size, 'bytes');
console.error('- File permissions:', stats.mode.toString(8));
const content = await fs.readFile(this.sourcesFile, 'utf-8');
console.error('- File content:', content);
}
else {
throw new Error('File was not created');
}
}
catch (error) {
console.error('Failed to save sources:', error);
console.error('Error details:', error instanceof Error ? error.message : String(error));
console.error('Stack trace:', error instanceof Error ? error.stack : 'No stack trace');
throw new FileSystemError('Failed to save documentation sources', error);
}
}
/**
* Loads documentation sources metadata
* @returns Array of documentation sources
* @throws {FileSystemError} If load operation fails
*/
async loadSources() {
try {
console.error('\nLoading sources:');
console.error('- Path:', this.sourcesFile);
await this.ensureDirectories();
console.error('- Directories ensured');
const exists = await fs
.access(this.sourcesFile)
.then(() => true)
.catch(() => false);
console.error('- File exists:', exists);
if (!exists) {
console.error('- No sources file found, returning empty array');
return [];
}
const content = await fs.readFile(this.sourcesFile, 'utf-8');
console.error('- File content length:', content.length);
const docs = JSON.parse(content);
console.error('- Parsed docs count:', docs.length);
return docs;
}
catch (error) {
console.error('Error loading sources:', error);
console.error('Error details:', error instanceof Error ? error.message : String(error));
return [];
}
}
/**
* Searches for text in cached documentation using streams
* @param name - Documentation name
* @param searchQuery - Text to search for
* @returns Whether the text was found
*/
async searchInDocumentation(name, searchQuery) {
try {
const filename = this.getDocumentationFileName(name);
const filePath = path.join(this.cacheDir, filename);
const content = await fs.readFile(filePath, 'utf-8');
const chunks = content.match(/.{1,1048576}/g) || []; // Split into 1MB chunks
for (const chunk of chunks) {
if (chunk.toLowerCase().includes(searchQuery.toLowerCase())) {
return true;
}
}
return false;
}
catch (error) {
if (error.code === 'ENOENT') {
return false;
}
throw new FileSystemError(`Failed to search documentation: ${name}`, error);
}
}
/**
* Lists all cached documentation files
* @returns Array of filenames
* @throws {FileSystemError} If directory reading fails
*/
async listDocumentationFiles() {
try {
await this.ensureDirectories();
return fs.readdir(this.cacheDir);
}
catch (error) {
throw new FileSystemError('Failed to list documentation files', error);
}
}
/**
* Gets documentation file path
* @param name - Documentation name
* @returns Path to documentation file
*/
getDocumentationPath(name) {
return path.join(this.cacheDir, this.getDocumentationFileName(name));
}
/**
* Checks if documentation exists in cache
* @param name - Documentation name
* @returns Whether documentation exists
*/
async hasDocumentation(name) {
try {
await fs.access(this.getDocumentationPath(name));
return true;
}
catch {
return false;
}
}
/**
* Gets documentation file name
* @param name - Documentation name
* @returns Sanitized file name
*/
getDocumentationFileName(name) {
return `${name.toLowerCase().replace(/\s+/g, '_')}.html`;
}
/**
* Gets base documentation path
* @returns Base path for documentation storage
*/
getDocsPath() {
return this.docsPath;
}
/**
* Gets cache directory path
* @returns Path to cache directory
*/
getCachePath() {
return this.cacheDir;
}
/**
* Updates cache configuration
* @param config - New cache configuration
*/
updateCacheConfig(config) {
this.cacheConfig = { ...this.cacheConfig, ...config };
this.startCleanupTimer(); // Restart timer with new interval
}
/**
* Cleanup resources
*/
destroy() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
}
}
//# sourceMappingURL=fs.js.map