Skip to main content
Glama
scanady
by scanady
storage.ts11.9 kB
/** * Storage service for file system operations */ import { promises as fs } from 'fs'; import path from 'path'; import { glob } from 'glob'; import { Standard, StandardMetadata } from '../types.js'; import { STANDARDS_DIR, MARKDOWN_EXTENSION, ERROR_MESSAGES } from '../constants.js'; import { parseStandard, serializeStandard } from './parser.js'; import { generateISODate, generateInitialVersion, validateMetadata } from './validator.js'; /** * Normalize incoming file paths so they are relative to the standards directory. * Accepts paths like 'spring-boot.md', 'standards/spring-boot.md', or absolute * paths. Returns a normalized relative path (e.g., 'spring-boot.md'). */ function normalizeTargetPath(p: string): string { if (!p) return p; // If absolute and inside STANDARDS_DIR, make relative; otherwise keep absolute if (path.isAbsolute(p)) { return path.relative(STANDARDS_DIR, p); } // Remove any leading 'standards/' prefix that may be provided by callers return p.replace(/^[/\\]?standards[/\\]/i, ''); } /** * Ensures the standards directory structure exists */ export async function ensureDirectoryStructure(): Promise<void> { // Only ensure the main standards directory exists. Standards will be stored in the // root of this folder (no nested type/tier/process subdirectories). await fs.mkdir(STANDARDS_DIR, { recursive: true }); } /** * Generates a file path from metadata */ export function generateFilePath( metadata: StandardMetadata, filename?: string ): string { const sanitizedFilename = filename ? sanitizeFilename(filename) : generateDefaultFilename(metadata); // Build final filename with prefix: <type>-<tier>-<process>-<status>-<sanitizedTitle>.md const prefixParts = [metadata.type, metadata.tier, metadata.process] .filter(Boolean) .map((p) => p.toLowerCase()); // sanitizedFilename includes .md; strip extension for joining let titleBase = sanitizedFilename; if (titleBase.endsWith(MARKDOWN_EXTENSION)) { titleBase = titleBase.slice(0, -MARKDOWN_EXTENSION.length); } const prefix = prefixParts.join('-') + '-'; const suffix = `-${metadata.status.toLowerCase()}`; // If the sanitized title already has prefix/suffix included, don't duplicate let finalBase = titleBase.toLowerCase(); if (finalBase.startsWith(prefix)) { finalBase = finalBase.slice(prefix.length); } if (finalBase.endsWith(suffix)) { finalBase = finalBase.slice(0, -suffix.length); } const finalName = `${prefix}${finalBase}${suffix}${MARKDOWN_EXTENSION}`; return finalName; } /** * Sanitizes a filename to be safe for the file system */ function sanitizeFilename(filename: string): string { // Strip a leading 'standards/' if present and take basename let base = filename.replace(/^[/\\]?standards[/\\]/i, ''); base = path.basename(base); // Remove .md if provided so we don't double append if (base.toLowerCase().endsWith(MARKDOWN_EXTENSION)) { base = base.slice(0, -MARKDOWN_EXTENSION.length); } // Convert to kebab-case: lowercase and replace non-alphanum with hyphens let sanitized = base .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); // Ensure it has the markdown extension sanitized += MARKDOWN_EXTENSION; return sanitized; } /** * Generates a default filename from metadata */ function generateDefaultFilename(metadata: StandardMetadata): string { // Prefer the first tag as a concise filename; otherwise use tier or type. if (metadata.tags && metadata.tags.length > 0) { return sanitizeFilename(metadata.tags[0]); } if (metadata.tier) { return sanitizeFilename(metadata.tier); } return sanitizeFilename(metadata.type); } /** * Reads a standard from the file system */ export async function readStandard(filePath: string): Promise<Standard> { try { const normalized = normalizeTargetPath(filePath); const fullPath = path.isAbsolute(normalized) ? normalized : path.join(STANDARDS_DIR, normalized); const content = await fs.readFile(fullPath, 'utf-8'); // Get relative path for the standard const relativePath = path.relative(STANDARDS_DIR, fullPath); return parseStandard(content, relativePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error(`${ERROR_MESSAGES.STANDARD_NOT_FOUND}: ${filePath}`); } throw new Error(`${ERROR_MESSAGES.FILE_READ_ERROR}: ${error}`); } } /** * Writes a standard to the file system */ export async function writeStandard( standard: Standard, targetPath?: string ): Promise<string> { try { const filePath = normalizeTargetPath(targetPath || standard.path); const fullPath = path.isAbsolute(filePath) ? filePath : path.join(STANDARDS_DIR, filePath); // Ensure directory exists await fs.mkdir(path.dirname(fullPath), { recursive: true }); // Serialize and write const content = serializeStandard(standard); await fs.writeFile(fullPath, content, 'utf-8'); return path.relative(STANDARDS_DIR, fullPath); } catch (error) { throw new Error(`${ERROR_MESSAGES.FILE_WRITE_ERROR}: ${error}`); } } /** * Creates a new standard */ export async function createStandard( metadata: Omit<StandardMetadata, 'version' | 'created' | 'updated'>, content: string, filename?: string ): Promise<Standard> { // Complete metadata with auto-generated fields const completeMetadata: StandardMetadata = { ...metadata, version: generateInitialVersion(), created: generateISODate(), updated: generateISODate(), }; // Validate metadata; normalize returns canonical metadata (e.g., singular type) const validatedMetadata = validateMetadata(completeMetadata); // Generate file path const filePath = normalizeTargetPath(generateFilePath(validatedMetadata, filename)); // Check if file already exists const fullPath = path.join(STANDARDS_DIR, filePath); try { await fs.access(fullPath); throw new Error(`Standard already exists at: ${filePath}`); } catch (error) { // File doesn't exist, which is what we want if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } const standard: Standard = { metadata: validatedMetadata, content, path: path.relative(STANDARDS_DIR, fullPath), }; await writeStandard(standard); return standard; } /** * Updates an existing standard */ export async function updateStandard( filePath: string, updates: { content?: string; metadata?: Partial<StandardMetadata>; } ): Promise<Standard> { // Read existing standard const existing = await readStandard(filePath); // Update content if provided const newContent = updates.content !== undefined ? updates.content : existing.content; // Merge metadata if provided const newMetadata = updates.metadata ? { ...existing.metadata, ...updates.metadata, updated: generateISODate() } : existing.metadata; // Validate updated metadata and get normalized form const validatedNewMetadata = validateMetadata(newMetadata); // Determine if the file name should change based on metadata changes. // We support both old (prefix-status-title) and new (prefix-title-status) filename patterns. const existingBasename = path.basename(existing.path).replace(/\.[^.]+$/, ''); // remove extension function extractTitleBaseFromFilename(basename: string, metadata: StandardMetadata): string { const parts = basename.split('-'); // If we have at least 5 parts, try to match old or new patterns if (parts.length >= 5) { const possibleStatus = parts[3]; const lastPart = parts[parts.length - 1]; // New pattern: type-tier-process-title-status (status at end) if (lastPart === metadata.status) { return parts.slice(3, parts.length - 1).join('-'); } // Old pattern: type-tier-process-status-title (status at position 3) if (possibleStatus === metadata.status) { return parts.slice(4).join('-'); } } // Fallback: everything after first 3 parts if (parts.length > 3) return parts.slice(3).join('-'); return basename; } const titleBase = extractTitleBaseFromFilename(existingBasename, existing.metadata); const newFilename = generateFilePath(validatedNewMetadata, titleBase); const newRelativePath = normalizeTargetPath(newFilename); const oldRelativePath = normalizeTargetPath(existing.path); const oldFullPath = path.join(STANDARDS_DIR, oldRelativePath); const newFullPath = path.join(STANDARDS_DIR, newRelativePath); const updated: Standard = { metadata: validatedNewMetadata, content: newContent, path: path.relative(STANDARDS_DIR, newFullPath), }; // If the new full path differs from the old, rename on disk; otherwise rewrite the same file if (oldFullPath !== newFullPath) { // Ensure target doesn't exist try { await fs.access(newFullPath); throw new Error(`Target file already exists: ${newRelativePath}`); } catch (e) { // If access threw because file doesn't exist, proceed if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { throw e; } } // Ensure directory exists (we only have root, but keep logic for future) await fs.mkdir(path.dirname(newFullPath), { recursive: true }); // Rename on disk await fs.rename(oldFullPath, newFullPath); // Rewrite with updated content to ensure metadata updated const contentToWrite = serializeStandard(updated); await fs.writeFile(newFullPath, contentToWrite, 'utf-8'); } else { await writeStandard(updated); } return updated; } /** * Deletes a standard from the file system */ export async function deleteStandard(filePath: string): Promise<void> { try { const normalized = normalizeTargetPath(filePath); const fullPath = path.isAbsolute(normalized) ? normalized : path.join(STANDARDS_DIR, normalized); await fs.unlink(fullPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error(`${ERROR_MESSAGES.STANDARD_NOT_FOUND}: ${filePath}`); } throw new Error(`Error deleting standard: ${error}`); } } /** * Lists all markdown files in the standards directory */ export async function listAllStandardFiles(): Promise<string[]> { // Use glob pattern relative to the standards directory to avoid OS-specific // path separator issues. This will only list markdown files at the root level // of the standards folder (no nested directories). const pattern = `*${MARKDOWN_EXTENSION}`; const files = await glob(pattern, { cwd: STANDARDS_DIR, nodir: true }); // glob returns paths relative to the cwd when 'cwd' is provided. Convert to // absolute paths so downstream code expects a full absolute path. const absoluteFiles = files.map((file) => path.join(STANDARDS_DIR, file)); // Return relative paths return absoluteFiles.map((file) => path.relative(STANDARDS_DIR, file)); } /** * Checks if a file exists */ export async function fileExists(filePath: string): Promise<boolean> { try { const normalized = normalizeTargetPath(filePath); const fullPath = path.isAbsolute(normalized) ? normalized : path.join(STANDARDS_DIR, normalized); await fs.access(fullPath); return true; } catch { return false; } } /** * Reads all standards from the file system */ export async function readAllStandards(): Promise<Standard[]> { const files = await listAllStandardFiles(); const standards: Standard[] = []; for (const file of files) { try { const standard = await readStandard(file); standards.push(standard); } catch (error) { console.error(`Error reading standard ${file}:`, error); // Continue with other files } } return standards; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/scanady/engineering-standards-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server