UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

281 lines 11.7 kB
import { promises as fs, constants as fsConstants } from 'fs'; import path from 'path'; function isMarkerOnOwnLine(content, markerIndex, markerLength) { let leftIndex = markerIndex - 1; while (leftIndex >= 0 && content[leftIndex] !== '\n') { const char = content[leftIndex]; if (char !== ' ' && char !== '\t' && char !== '\r') { return false; } leftIndex--; } let rightIndex = markerIndex + markerLength; while (rightIndex < content.length && content[rightIndex] !== '\n') { const char = content[rightIndex]; if (char !== ' ' && char !== '\t' && char !== '\r') { return false; } rightIndex++; } return true; } function findMarkerIndex(content, marker, fromIndex = 0) { let currentIndex = content.indexOf(marker, fromIndex); while (currentIndex !== -1) { if (isMarkerOnOwnLine(content, currentIndex, marker.length)) { return currentIndex; } currentIndex = content.indexOf(marker, currentIndex + marker.length); } return -1; } export class FileSystemUtils { /** * Converts a path to use forward slashes (POSIX style). * Essential for cross-platform compatibility with glob libraries like fast-glob. */ static toPosixPath(p) { return p.replace(/\\/g, '/'); } static isWindowsBasePath(basePath) { return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\'); } static normalizeSegments(segments) { return segments .flatMap((segment) => segment.split(/[\\/]+/u)) .filter((part) => part.length > 0); } static joinPath(basePath, ...segments) { const normalizedSegments = this.normalizeSegments(segments); if (this.isWindowsBasePath(basePath)) { const normalizedBasePath = path.win32.normalize(basePath); return normalizedSegments.length ? path.win32.join(normalizedBasePath, ...normalizedSegments) : normalizedBasePath; } const posixBasePath = basePath.replace(/\\/g, '/'); return normalizedSegments.length ? path.posix.join(posixBasePath, ...normalizedSegments) : path.posix.normalize(posixBasePath); } static async createDirectory(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } static async fileExists(filePath) { try { await fs.access(filePath); return true; } catch (error) { if (error.code !== 'ENOENT') { console.debug(`Unable to check if file exists at ${filePath}: ${error.message}`); } return false; } } /** * Finds the first existing parent directory by walking up the directory tree. * @param dirPath Starting directory path * @returns The first existing directory path, or null if root is reached without finding one */ static async findFirstExistingDirectory(dirPath) { let currentDir = dirPath; while (true) { try { const stats = await fs.stat(currentDir); if (stats.isDirectory()) { return currentDir; } // Path component exists but is not a directory (edge case) console.debug(`Path component ${currentDir} exists but is not a directory`); return null; } catch (error) { if (error.code === 'ENOENT') { // Directory doesn't exist, move up one level const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { // Reached filesystem root without finding existing directory return null; } currentDir = parentDir; } else { // Unexpected error (permissions, I/O error, etc.) console.debug(`Error checking directory ${currentDir}: ${error.message}`); return null; } } } } static async canWriteFile(filePath) { try { const stats = await fs.stat(filePath); if (!stats.isFile()) { return true; } // On Windows, stats.mode doesn't reliably indicate write permissions. // Use fs.access with W_OK to check actual write permissions cross-platform. try { await fs.access(filePath, fsConstants.W_OK); return true; } catch { return false; } } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist - find first existing parent directory and check its permissions const parentDir = path.dirname(filePath); const existingDir = await this.findFirstExistingDirectory(parentDir); if (existingDir === null) { // No existing parent directory found (edge case) return false; } // Check if the existing parent directory is writable try { await fs.access(existingDir, fsConstants.W_OK); return true; } catch { return false; } } console.debug(`Unable to determine write permissions for ${filePath}: ${error.message}`); return false; } } static async directoryExists(dirPath) { try { const stats = await fs.stat(dirPath); return stats.isDirectory(); } catch (error) { if (error.code !== 'ENOENT') { console.debug(`Unable to check if directory exists at ${dirPath}: ${error.message}`); } return false; } } static async writeFile(filePath, content) { const dir = path.dirname(filePath); await this.createDirectory(dir); await fs.writeFile(filePath, content, 'utf-8'); } static async readFile(filePath) { return await fs.readFile(filePath, 'utf-8'); } static async updateFileWithMarkers(filePath, content, startMarker, endMarker) { let existingContent = ''; if (await this.fileExists(filePath)) { existingContent = await this.readFile(filePath); const startIndex = findMarkerIndex(existingContent, startMarker); const endIndex = startIndex !== -1 ? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length) : findMarkerIndex(existingContent, endMarker); if (startIndex !== -1 && endIndex !== -1) { if (endIndex < startIndex) { throw new Error(`Invalid marker state in ${filePath}. End marker appears before start marker.`); } const before = existingContent.substring(0, startIndex); const after = existingContent.substring(endIndex + endMarker.length); existingContent = before + startMarker + '\n' + content + '\n' + endMarker + after; } else if (startIndex === -1 && endIndex === -1) { existingContent = startMarker + '\n' + content + '\n' + endMarker + '\n\n' + existingContent; } else { throw new Error(`Invalid marker state in ${filePath}. Found start: ${startIndex !== -1}, Found end: ${endIndex !== -1}`); } } else { existingContent = startMarker + '\n' + content + '\n' + endMarker; } await this.writeFile(filePath, existingContent); } static async ensureWritePermissions(dirPath) { try { // If directory doesn't exist, check parent directory permissions if (!await this.directoryExists(dirPath)) { const parentDir = path.dirname(dirPath); if (!await this.directoryExists(parentDir)) { await this.createDirectory(parentDir); } return await this.ensureWritePermissions(parentDir); } const testFile = path.join(dirPath, '.openspec-test-' + Date.now() + '-' + Math.random().toString(36).slice(2)); await fs.writeFile(testFile, ''); // On Windows, file may be temporarily locked by antivirus or indexing services. // Retry unlink with a small delay if it fails. const maxRetries = 3; for (let attempt = 0; attempt < maxRetries; attempt++) { try { await fs.unlink(testFile); break; } catch (unlinkError) { if (attempt === maxRetries - 1) { // Last attempt failed, but we successfully wrote the file, so permissions are OK // Just log and continue - the temp file will be cleaned up eventually console.debug(`Could not clean up test file ${testFile}: ${unlinkError.message}`); } else { // Wait briefly before retrying (Windows file lock release) await new Promise((resolve) => setTimeout(resolve, 50)); } } } return true; } catch (error) { console.debug(`Insufficient permissions to write to ${dirPath}: ${error.message}`); return false; } } } /** * Removes a marker block from file content. * Only removes markers that are on their own lines (ignores inline mentions). * Cleans up double blank lines that may result from removal. * * @param content - File content with markers * @param startMarker - The start marker string * @param endMarker - The end marker string * @returns Content with marker block removed, or original content if markers not found/invalid */ export function removeMarkerBlock(content, startMarker, endMarker) { const startIndex = findMarkerIndex(content, startMarker); const endIndex = startIndex !== -1 ? findMarkerIndex(content, endMarker, startIndex + startMarker.length) : findMarkerIndex(content, endMarker); if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { return content; } // Find the start of the line containing the start marker let lineStart = startIndex; while (lineStart > 0 && content[lineStart - 1] !== '\n') { lineStart--; } // Find the end of the line containing the end marker let lineEnd = endIndex + endMarker.length; while (lineEnd < content.length && content[lineEnd] !== '\n') { lineEnd++; } // Include the trailing newline if present if (lineEnd < content.length && content[lineEnd] === '\n') { lineEnd++; } const before = content.substring(0, lineStart); const after = content.substring(lineEnd); // Clean up double blank lines (handle both Unix \n and Windows \r\n) let result = before + after; result = result.replace(/(\r?\n){3,}/g, '\n\n'); // Trim trailing whitespace but preserve leading whitespace and original newline style if (result.trimEnd() === '') { return ''; } const newline = content.includes('\r\n') ? '\r\n' : '\n'; return result.trimEnd() + newline; } //# sourceMappingURL=file-system.js.map