Skip to main content
Glama
utils.ts13.5 kB
import { spawn, SpawnOptions } from 'child_process'; import { promisify } from 'util'; import * as path from 'path'; import * as fs from 'fs'; import { SvnConfig, SvnResponse, SvnError, SvnInfo, SvnStatus, SvnLogEntry, SVN_STATUS_CODES } from './types.js'; import iconv from 'iconv-lite'; /** * Crear configuración de SVN desde variables de entorno y parámetros */ export function createSvnConfig(overrides: Partial<SvnConfig> = {}): SvnConfig { return { svnPath: overrides.svnPath || process.env.SVN_PATH || 'svn', workingDirectory: overrides.workingDirectory || process.env.SVN_WORKING_DIRECTORY || process.cwd(), username: overrides.username || process.env.SVN_USERNAME, password: overrides.password || process.env.SVN_PASSWORD, timeout: overrides.timeout || parseInt(process.env.SVN_TIMEOUT || '30000', 10) }; } /** * Validar que SVN esté disponible en el sistema */ export async function validateSvnInstallation(config: SvnConfig): Promise<boolean> { try { const result = await executeSvnCommand(config, ['--version', '--quiet']); return result.success; } catch (error) { return false; } } /** * Detectar si el directorio actual es un working copy de SVN */ export async function isWorkingCopy(workingDirectory: string): Promise<boolean> { try { const svnDir = path.join(workingDirectory, '.svn'); return await promisify(fs.access)(svnDir).then(() => true).catch(() => false); } catch { return false; } } /** * Normalizar rutas para Windows */ export function normalizePath(filePath: string): string { return path.resolve(filePath).replace(/\\/g, '/'); } /** * Escapar argumentos para línea de comandos en Windows */ export function escapeArgument(arg: string): string { // Si el argumento contiene espacios o caracteres especiales, lo encerramos en comillas if (/[\s&()<>[\]{}^=;!'+,`~%]/.test(arg)) { return `"${arg.replace(/"/g, '""')}"`; } return arg; } /** * Construir argumentos de autenticación */ export function buildAuthArgs(config: SvnConfig, options: { noAuthCache?: boolean } = {}): string[] { const args: string[] = []; if (config.username) { args.push('--username', config.username); } if (config.password) { args.push('--password', config.password); } // Siempre usar --non-interactive para evitar prompts args.push('--non-interactive'); // Opción para no usar cache de credenciales (útil para E215004) if (options.noAuthCache) { args.push('--no-auth-cache'); } return args; } /** * Ejecutar comando SVN con manejo de errores mejorado */ export async function executeSvnCommand( config: SvnConfig, args: string[], options: { input?: string; encoding?: BufferEncoding; noAuthCache?: boolean } = {} ): Promise<SvnResponse> { const startTime = Date.now(); // Agregar argumentos de autenticación const finalArgs = [...args, ...buildAuthArgs(config, { noAuthCache: options.noAuthCache })]; const command = `${config.svnPath} ${finalArgs.join(' ')}`; return new Promise((resolve, reject) => { // Configurar opciones de spawn para Windows const spawnOptions: SpawnOptions = { cwd: config.workingDirectory, shell: true, // Importante para Windows stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, // Asegurar que SVN use UTF-8 LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' } }; const childProcess = spawn(config.svnPath!, finalArgs, spawnOptions); let stdout = ''; let stderr = ''; // Create streaming decoders to properly handle multi-byte characters across chunk boundaries // Using UTF-8 as SVN output is configured to use UTF-8 via LANG/LC_ALL environment variables // The encoding is consistent throughout the stream - it doesn't change mid-stream as per SVN behavior const stdoutDecoder = iconv.getDecoder('utf8', { stripBOM: false, addBOM: false }); const stderrDecoder = iconv.getDecoder('utf8', { stripBOM: false, addBOM: false }); // Configurar timeout const timeout = setTimeout(() => { childProcess.kill('SIGTERM'); reject(new SvnError(`Command timeout after ${config.timeout}ms: ${command}`)); }, config.timeout); // Capturar stdout using streaming decoder to handle multi-byte characters correctly childProcess.stdout?.on('data', (data) => { stdout += stdoutDecoder.write(data); }); // Capturar stderr using streaming decoder to handle multi-byte characters correctly childProcess.stderr?.on('data', (data) => { stderr += stderrDecoder.write(data); }); // Enviar input si se proporciona if (options.input && childProcess.stdin) { childProcess.stdin.write(options.input); childProcess.stdin.end(); } // Manejar finalización del proceso childProcess.on('close', (code) => { clearTimeout(timeout); // Finalize decoders to flush any remaining buffered data stdout += stdoutDecoder.end(); stderr += stderrDecoder.end(); const executionTime = Date.now() - startTime; const response: SvnResponse = { success: code === 0, command, workingDirectory: config.workingDirectory!, executionTime }; if (code === 0) { response.data = stdout.trim(); resolve(response); } else { const error = new SvnError(`SVN command failed with code ${code}: ${command}`); error.code = code || undefined; error.stderr = stderr.trim(); error.command = command; response.error = error.message; response.data = stderr.trim(); reject(error); } }); // Manejar errores del proceso childProcess.on('error', (error) => { clearTimeout(timeout); const svnError = new SvnError(`Failed to execute SVN command: ${error.message}`); svnError.command = command; reject(svnError); }); }); } /** * Parsear output XML de SVN */ export function parseXmlOutput(xmlString: string): any { // Implementación básica de parsing XML // En un entorno de producción, sería mejor usar una librería como xml2js try { // Esta es una implementación simplificada para Node.js // En navegadores se usaría DOMParser, pero en Node.js necesitamos otra aproximación const lines = xmlString.split('\n'); const result: any = {}; for (const line of lines) { const match = line.match(/<([^>]+)>([^<]+)<\/\1>/); if (match) { result[match[1]] = match[2]; } } return result; } catch (error) { throw new SvnError(`Failed to parse XML output: ${error}`); } } /** * Parsear información de svn info */ export function parseInfoOutput(output: string): SvnInfo { const lines = output.split('\n'); const info: Partial<SvnInfo> = {}; for (const line of lines) { const [key, ...valueParts] = line.split(': '); const value = valueParts.join(': ').trim(); switch (key.trim()) { case 'Path': info.path = value; break; case 'Working Copy Root Path': info.workingCopyRootPath = value; break; case 'URL': info.url = value; break; case 'Relative URL': info.relativeUrl = value; break; case 'Repository Root': info.repositoryRoot = value; break; case 'Repository UUID': info.repositoryUuid = value; break; case 'Revision': info.revision = parseInt(value, 10); break; case 'Node Kind': info.nodeKind = value as 'file' | 'directory'; break; case 'Schedule': info.schedule = value; break; case 'Last Changed Author': info.lastChangedAuthor = value; break; case 'Last Changed Rev': info.lastChangedRev = parseInt(value, 10); break; case 'Last Changed Date': info.lastChangedDate = value; break; case 'Text Last Updated': info.textLastUpdated = value; break; case 'Checksum': info.checksum = value; break; } } return info as SvnInfo; } /** * Parsear output de svn status */ export function parseStatusOutput(output: string): SvnStatus[] { const lines = output.split('\n').filter(line => line.trim()); const statusList: SvnStatus[] = []; for (const line of lines) { if (line.length < 8) continue; const statusCode = line[0]; const propStatusCode = line[1]; const path = line.substring(8).trim(); const status: SvnStatus = { path, status: (SVN_STATUS_CODES as any)[statusCode] || 'unknown' }; statusList.push(status); } return statusList; } /** * Parsear output de svn log */ export function parseLogOutput(output: string): SvnLogEntry[] { const entries: SvnLogEntry[] = []; if (!output || output.trim().length === 0) { return entries; } // Dividir por las líneas separadoras de SVN log const logEntries = output.split(/^-{72}$/gm).filter(entry => entry.trim()); for (const entryText of logEntries) { const lines = entryText.trim().split('\n'); if (lines.length < 2) continue; const headerLine = lines[0]; // Patrón más flexible para el header const headerMatch = headerLine.match(/^r(\d+)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*(.*)$/); if (headerMatch) { try { const [, revision, author, date, details] = headerMatch; const message = lines.slice(2).join('\n').trim(); entries.push({ revision: parseInt(revision, 10), author: author.trim(), date: date.trim(), message: message || 'Sin mensaje' }); } catch (parseError) { console.warn(`Warning: Failed to parse log entry: ${parseError}`); continue; } } } return entries; } /** * Formatear duración en milisegundos a formato legible */ export function formatDuration(milliseconds: number): string { if (milliseconds < 1000) { return `${milliseconds}ms`; } const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } /** * Validar nombre de archivo/directorio */ export function validatePath(filePath: string): boolean { // Verificar que no contenga caracteres prohibidos en Windows // Pero permitir dos puntos en contextos válidos (drive letters en Windows) // Patrón para detectar rutas absolutas de Windows (C:, D:, etc.) const windowsAbsolutePathPattern = /^[A-Za-z]:[\\\/]/; if (windowsAbsolutePathPattern.test(filePath)) { // Para rutas absolutas de Windows, verificar solo después del drive letter const pathAfterDrive = filePath.substring(2); // Quitar "C:" o similar const invalidChars = /[<>:"|?*]/; return !invalidChars.test(pathAfterDrive); } else { // Para todas las demás rutas, aplicar validación completa const invalidChars = /[<>:"|?*]/; return !invalidChars.test(filePath); } } /** * Obtener rutas relativas desde el directorio de trabajo */ export function getRelativePath(fullPath: string, workingDirectory: string): string { return path.relative(workingDirectory, fullPath).replace(/\\/g, '/'); } /** * Validar URL de repositorio SVN */ export function validateSvnUrl(url: string): boolean { const svnUrlPattern = /^(svn|https?|file):\/\/.+/i; return svnUrlPattern.test(url); } /** * Limpiar y normalizar salida de comando */ export function cleanOutput(output: string): string { return output .replace(/\r\n/g, '\n') // Normalizar line endings .replace(/\r/g, '\n') // Convertir CR a LF .trim(); } /** * Crear mensaje de error SVN más descriptivo */ export function createSvnError(message: string, command?: string, stderr?: string): SvnError { const error = new SvnError(message); if (command) error.command = command; if (stderr) error.stderr = stderr; return error; } /** * Limpiar cache de credenciales SVN para resolver errores E215004 */ export async function clearSvnCredentials(config: SvnConfig): Promise<SvnResponse> { try { // En sistemas Unix/Linux, SVN guarda credenciales en ~/.subversion/auth // En Windows, en %APPDATA%\Subversion\auth // Intentar limpiar usando el comando auth específico si está disponible // Primero intentar con el comando de limpieza estándar return await executeSvnCommand(config, ['auth', '--remove'], { noAuthCache: true }); } catch (error: any) { // Si el comando auth no está disponible, intentar alternativa try { // Como fallback, usar un comando que no guarde credenciales const response = await executeSvnCommand(config, ['info', '--non-interactive'], { noAuthCache: true }); return { success: true, data: 'Cache de credenciales limpiado (usando método alternativo)', command: 'clear-credentials', workingDirectory: config.workingDirectory! }; } catch (fallbackError: any) { return { success: false, error: `No se pudo limpiar el cache de credenciales: ${fallbackError.message}`, command: 'clear-credentials', workingDirectory: config.workingDirectory! }; } } }

Implementation Reference

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/gcorroto/mcp-svn'

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