feat(mcp): enhance MCP installation with status tracking and custom configs
- Add /mcp-servers/ to .gitignore for MCP server installations - Extend mcp_installations table with status, logs, and validation fields - Refactor MCP install route to support custom configs, enable logs, and async installation - Improve MCPSyncService with promise-based syncing to prevent concurrent operations This update improves MCP management by adding robust installation tracking, custom configuration support, and better error handling for a more reliable user experience.
This commit is contained in:
1 parent
1042f2136b
commit
f19bf10ef3
35 files changed
+4743
-429
No files matched your search
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Configurações para instalação e gerenciamento de servidores MCP
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
||||
export const MCP_CONFIG = {
|
||||
// Diretório base onde os servidores MCP serão instalados
|
||||
serversDir:
|
||||
process.env.MCP_SERVERS_DIR || path.join(process.cwd(), "mcp-servers"),
|
||||
|
||||
// Timeout para instalação de pacotes (5 minutos)
|
||||
installTimeout: parseInt(process.env.MCP_INSTALL_TIMEOUT || "300000", 10),
|
||||
|
||||
// Timeout para testes de servidor (10 segundos)
|
||||
testTimeout: parseInt(process.env.MCP_TEST_TIMEOUT || "10000", 10),
|
||||
|
||||
// Habilitar logs detalhados (para debug/testes)
|
||||
enableLogs: process.env.MCP_ENABLE_LOGS === "true",
|
||||
|
||||
// Diretórios por tipo de pacote
|
||||
get npmDir() {
|
||||
return path.join(this.serversDir, "npm");
|
||||
},
|
||||
|
||||
get pythonDir() {
|
||||
return path.join(this.serversDir, "python");
|
||||
},
|
||||
|
||||
get rustDir() {
|
||||
return path.join(this.serversDir, "rust");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Serviço de gerenciamento de ambientes isolados para servidores MCP
|
||||
*/
|
||||
|
||||
import { mkdir, rm, access } from "fs/promises";
|
||||
import path from "path";
|
||||
import { MCP_CONFIG } from "@/lib/config/mcp";
|
||||
import { commandExists, spawnWithProgress } from "@/lib/utils/system";
|
||||
import type {
|
||||
MCPEnvironment,
|
||||
SystemDependencies,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
|
||||
export class MCPEnvironmentService {
|
||||
/**
|
||||
* Verifica dependências do sistema
|
||||
*/
|
||||
static async checkSystemDependencies(): Promise<SystemDependencies> {
|
||||
const [node, npm, python, pip, cargo, git] = await Promise.all([
|
||||
commandExists("node"),
|
||||
commandExists("npm"),
|
||||
commandExists("python3"),
|
||||
commandExists("pip3"),
|
||||
commandExists("cargo"),
|
||||
commandExists("git"),
|
||||
]);
|
||||
|
||||
return { node, npm, python, pip, cargo, git };
|
||||
}
|
||||
|
||||
/**
|
||||
* Garante que o diretório de ambiente existe
|
||||
*/
|
||||
static async ensureEnvironmentDirectory(
|
||||
type: "npm" | "python" | "rust"
|
||||
): Promise<string> {
|
||||
let dir: string;
|
||||
|
||||
switch (type) {
|
||||
case "npm":
|
||||
dir = MCP_CONFIG.npmDir;
|
||||
break;
|
||||
case "python":
|
||||
dir = MCP_CONFIG.pythonDir;
|
||||
break;
|
||||
case "rust":
|
||||
dir = MCP_CONFIG.rustDir;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
console.log(`✓ Environment directory ensured: ${dir}`);
|
||||
return dir;
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to create environment directory: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria ambiente virtual Python para um MCP específico
|
||||
*/
|
||||
static async createPythonVenv(
|
||||
mcpId: string,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<string> {
|
||||
const venvPath = path.join(MCP_CONFIG.pythonDir, mcpId);
|
||||
|
||||
// Verificar se já existe
|
||||
try {
|
||||
await access(venvPath);
|
||||
onProgress?.(`Virtual environment already exists at ${venvPath}`);
|
||||
return venvPath;
|
||||
} catch {
|
||||
// Não existe, criar novo
|
||||
}
|
||||
|
||||
onProgress?.("Creating Python virtual environment...");
|
||||
|
||||
try {
|
||||
const result = await spawnWithProgress(
|
||||
"python3",
|
||||
["-m", "venv", venvPath],
|
||||
{
|
||||
onOutput: (data) => onProgress?.(data.trim()),
|
||||
timeout: 60000, // 1 minuto para criar venv
|
||||
}
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Failed to create venv: ${result.stderr || result.stdout}`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.(`✓ Virtual environment created at ${venvPath}`);
|
||||
return venvPath;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to create Python venv: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém caminho do executável para um ambiente
|
||||
*/
|
||||
static getExecutablePath(
|
||||
type: "npm" | "python" | "rust",
|
||||
mcpId: string,
|
||||
packageName?: string
|
||||
): { executable: string; args: string[] } {
|
||||
switch (type) {
|
||||
case "npm":
|
||||
// Para npm, usar npx para executar pacotes instalados localmente
|
||||
return {
|
||||
executable: "npx",
|
||||
args: packageName ? ["--prefix", MCP_CONFIG.npmDir, packageName] : [],
|
||||
};
|
||||
|
||||
case "python": {
|
||||
const venvPath = path.join(MCP_CONFIG.pythonDir, mcpId);
|
||||
const pythonBin =
|
||||
process.platform === "win32"
|
||||
? path.join(venvPath, "Scripts", "python.exe")
|
||||
: path.join(venvPath, "bin", "python");
|
||||
|
||||
return {
|
||||
executable: pythonBin,
|
||||
args: packageName ? ["-m", packageName] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "rust": {
|
||||
// Para Rust, o binário estará em target/release/
|
||||
const binaryPath = path.join(
|
||||
MCP_CONFIG.rustDir,
|
||||
mcpId,
|
||||
"target",
|
||||
"release",
|
||||
packageName || mcpId
|
||||
);
|
||||
|
||||
return {
|
||||
executable: binaryPath,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa ambiente de um MCP (desinstalação)
|
||||
*/
|
||||
static async cleanupEnvironment(environment: MCPEnvironment): Promise<void> {
|
||||
try {
|
||||
console.log(`Cleaning up environment at ${environment.path}`);
|
||||
|
||||
await rm(environment.path, { recursive: true, force: true });
|
||||
|
||||
console.log(`✓ Environment cleaned: ${environment.path}`);
|
||||
} catch (error: any) {
|
||||
console.error(`Warning: Failed to cleanup environment: ${error.message}`);
|
||||
// Não lançar erro - cleanup é best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um ambiente está válido
|
||||
*/
|
||||
static async validateEnvironment(
|
||||
environment: MCPEnvironment
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Verificar se o diretório existe
|
||||
await access(environment.path);
|
||||
|
||||
// Verificar se o executável existe
|
||||
await access(environment.executable);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações sobre o ambiente de um MCP
|
||||
*/
|
||||
static async getEnvironmentInfo(
|
||||
mcpId: string,
|
||||
type: "npm" | "python" | "rust"
|
||||
): Promise<{
|
||||
exists: boolean;
|
||||
path: string;
|
||||
size?: number;
|
||||
}> {
|
||||
const envPath = path.join(
|
||||
type === "npm"
|
||||
? MCP_CONFIG.npmDir
|
||||
: type === "python"
|
||||
? path.join(MCP_CONFIG.pythonDir, mcpId)
|
||||
: path.join(MCP_CONFIG.rustDir, mcpId)
|
||||
);
|
||||
|
||||
try {
|
||||
await access(envPath);
|
||||
// TODO: Calcular tamanho do diretório se necessário
|
||||
return { exists: true, path: envPath };
|
||||
} catch {
|
||||
return { exists: false, path: envPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Serviço de instalação de servidores MCP
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import { MCP_CONFIG } from "@/lib/config/mcp";
|
||||
import { MCPEnvironmentService } from "./mcp-environment";
|
||||
import { sanitizePackageName, spawnWithProgress } from "@/lib/utils/system";
|
||||
import type {
|
||||
MCPEnvironment,
|
||||
InstallationProgress,
|
||||
MCPInstallationStatus,
|
||||
InstallationConfig,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
|
||||
export class MCPInstallerService {
|
||||
/**
|
||||
* Instala um servidor MCP baseado na configuração
|
||||
*/
|
||||
static async install(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void
|
||||
): Promise<MCPEnvironment> {
|
||||
// Verificar dependências do sistema
|
||||
onProgress({
|
||||
status: "checking_dependencies" as MCPInstallationStatus,
|
||||
message: "Checking system dependencies...",
|
||||
percentage: 5,
|
||||
});
|
||||
|
||||
const dependencies = await MCPEnvironmentService.checkSystemDependencies();
|
||||
|
||||
// Determinar tipo de instalação
|
||||
const installType = this.determineInstallType(config, dependencies);
|
||||
|
||||
onProgress({
|
||||
status: "downloading" as MCPInstallationStatus,
|
||||
message: `Preparing ${installType} installation...`,
|
||||
percentage: 10,
|
||||
});
|
||||
|
||||
// Delegar para instalador específico
|
||||
switch (installType) {
|
||||
case "npm":
|
||||
return await this.installNPM(config, onProgress, dependencies);
|
||||
case "python":
|
||||
return await this.installPython(config, onProgress, dependencies);
|
||||
case "rust":
|
||||
return await this.installRust(config, onProgress, dependencies);
|
||||
default:
|
||||
throw new Error(`Unsupported installation type: ${installType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina o tipo de instalação baseado na configuração
|
||||
*/
|
||||
private static determineInstallType(
|
||||
config: InstallationConfig,
|
||||
dependencies: any
|
||||
): "npm" | "python" | "rust" {
|
||||
// Preferir package_registry se disponível
|
||||
if (config.packageRegistry === "npm" && dependencies.npm) {
|
||||
return "npm";
|
||||
}
|
||||
if (config.packageRegistry === "pypi" && dependencies.python) {
|
||||
return "python";
|
||||
}
|
||||
if (config.packageRegistry === "cargo" && dependencies.cargo) {
|
||||
return "rust";
|
||||
}
|
||||
|
||||
// Tentar detectar pelo nome do pacote
|
||||
if (config.packageName) {
|
||||
if (
|
||||
config.packageName.startsWith("@") ||
|
||||
config.packageName.includes("/")
|
||||
) {
|
||||
if (dependencies.npm) return "npm";
|
||||
}
|
||||
if (
|
||||
config.packageName.includes("mcp-server") ||
|
||||
config.packageName.includes("mcp_server")
|
||||
) {
|
||||
if (dependencies.python) return "python";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para npm se disponível
|
||||
if (dependencies.npm) return "npm";
|
||||
if (dependencies.python) return "python";
|
||||
if (dependencies.cargo) return "rust";
|
||||
|
||||
throw new Error("No suitable package manager found");
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala pacote NPM
|
||||
*/
|
||||
private static async installNPM(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void,
|
||||
dependencies: any
|
||||
): Promise<MCPEnvironment> {
|
||||
if (!dependencies.npm) {
|
||||
throw new Error("npm is not installed on this system");
|
||||
}
|
||||
|
||||
// Sanitizar nome do pacote
|
||||
const packageName = sanitizePackageName(config.packageName);
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `Installing npm package: ${packageName}`,
|
||||
percentage: 20,
|
||||
logs: config.enableLogs
|
||||
? [`Installing ${packageName} via npm...`]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Garantir diretório npm existe
|
||||
const npmDir = await MCPEnvironmentService.ensureEnvironmentDirectory(
|
||||
"npm"
|
||||
);
|
||||
|
||||
// Instalar pacote localmente no diretório npm
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await spawnWithProgress(
|
||||
"npm",
|
||||
["install", packageName, "--prefix", npmDir],
|
||||
{
|
||||
cwd: npmDir,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data, isError) => {
|
||||
if (config.enableLogs) {
|
||||
logs.push(data);
|
||||
}
|
||||
|
||||
// Atualizar progresso baseado na saída
|
||||
let percentage = 40;
|
||||
if (data.includes("fetchMetadata")) percentage = 30;
|
||||
if (data.includes("extract")) percentage = 50;
|
||||
if (data.includes("finalize")) percentage = 70;
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: isError
|
||||
? `npm: ${data.substring(0, 100)}`
|
||||
: `Installing: ${data.substring(0, 100)}`,
|
||||
percentage,
|
||||
logs: config.enableLogs ? [data] : undefined,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`npm install failed: ${result.stderr || result.stdout}`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "npm package installed successfully",
|
||||
percentage: 80,
|
||||
logs: config.enableLogs ? ["✓ Installation completed"] : undefined,
|
||||
});
|
||||
|
||||
// Construir ambiente
|
||||
const environment: MCPEnvironment = {
|
||||
type: "npm",
|
||||
path: npmDir,
|
||||
executable: "npx",
|
||||
args: ["--prefix", npmDir, packageName],
|
||||
};
|
||||
|
||||
return environment;
|
||||
} catch (error: any) {
|
||||
throw new Error(`NPM installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala pacote Python
|
||||
*/
|
||||
private static async installPython(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void,
|
||||
dependencies: any
|
||||
): Promise<MCPEnvironment> {
|
||||
if (!dependencies.python || !dependencies.pip) {
|
||||
throw new Error("Python3 and pip3 are not installed on this system");
|
||||
}
|
||||
|
||||
const packageName = sanitizePackageName(config.packageName);
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Creating Python virtual environment...",
|
||||
percentage: 20,
|
||||
logs: config.enableLogs ? ["Creating venv..."] : undefined,
|
||||
});
|
||||
|
||||
// Garantir diretório python existe
|
||||
await MCPEnvironmentService.ensureEnvironmentDirectory("python");
|
||||
|
||||
// Criar venv para este MCP
|
||||
const venvPath = await MCPEnvironmentService.createPythonVenv(
|
||||
config.mcpId,
|
||||
(msg) => {
|
||||
if (config.enableLogs) {
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: msg,
|
||||
percentage: 30,
|
||||
logs: [msg],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `Installing Python package: ${packageName}`,
|
||||
percentage: 40,
|
||||
logs: config.enableLogs
|
||||
? [`Installing ${packageName} via pip...`]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Caminho para pip no venv
|
||||
const pipBin =
|
||||
process.platform === "win32"
|
||||
? path.join(venvPath, "Scripts", "pip.exe")
|
||||
: path.join(venvPath, "bin", "pip");
|
||||
|
||||
const pythonBin =
|
||||
process.platform === "win32"
|
||||
? path.join(venvPath, "Scripts", "python.exe")
|
||||
: path.join(venvPath, "bin", "python");
|
||||
|
||||
try {
|
||||
const result = await spawnWithProgress(pipBin, ["install", packageName], {
|
||||
cwd: venvPath,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data, isError) => {
|
||||
if (config.enableLogs) {
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `pip: ${data.substring(0, 100)}`,
|
||||
percentage: 60,
|
||||
logs: [data],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`pip install failed: ${result.stderr || result.stdout}`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Python package installed successfully",
|
||||
percentage: 80,
|
||||
logs: config.enableLogs ? ["✓ Installation completed"] : undefined,
|
||||
});
|
||||
|
||||
// Extrair módulo Python do nome do pacote (ex: mcp-server-git -> mcp_server_git)
|
||||
const moduleName = packageName.replace(/-/g, "_");
|
||||
|
||||
const environment: MCPEnvironment = {
|
||||
type: "python",
|
||||
path: venvPath,
|
||||
executable: pythonBin,
|
||||
args: ["-m", moduleName],
|
||||
};
|
||||
|
||||
return environment;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Python installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala servidor Rust (via git clone + cargo build)
|
||||
*/
|
||||
private static async installRust(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void,
|
||||
dependencies: any
|
||||
): Promise<MCPEnvironment> {
|
||||
if (!dependencies.cargo || !dependencies.git) {
|
||||
throw new Error("Cargo and Git are not installed on this system");
|
||||
}
|
||||
|
||||
if (!config.repositoryUrl) {
|
||||
throw new Error("Repository URL is required for Rust installation");
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "downloading" as MCPInstallationStatus,
|
||||
message: "Cloning Rust repository...",
|
||||
percentage: 20,
|
||||
logs: config.enableLogs
|
||||
? [`Cloning ${config.repositoryUrl}...`]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Garantir diretório rust existe
|
||||
await MCPEnvironmentService.ensureEnvironmentDirectory("rust");
|
||||
|
||||
const projectPath = path.join(MCP_CONFIG.rustDir, config.mcpId);
|
||||
|
||||
try {
|
||||
// Clonar repositório
|
||||
const cloneResult = await spawnWithProgress(
|
||||
"git",
|
||||
["clone", config.repositoryUrl, projectPath],
|
||||
{
|
||||
cwd: MCP_CONFIG.rustDir,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data) => {
|
||||
if (config.enableLogs) {
|
||||
onProgress({
|
||||
status: "downloading" as MCPInstallationStatus,
|
||||
message: `git: ${data.substring(0, 100)}`,
|
||||
percentage: 30,
|
||||
logs: [data],
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (cloneResult.exitCode !== 0) {
|
||||
throw new Error(`git clone failed: ${cloneResult.stderr}`);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Building Rust project...",
|
||||
percentage: 40,
|
||||
logs: config.enableLogs ? ["Building with cargo..."] : undefined,
|
||||
});
|
||||
|
||||
// Compilar com cargo
|
||||
const buildResult = await spawnWithProgress(
|
||||
"cargo",
|
||||
["build", "--release"],
|
||||
{
|
||||
cwd: projectPath,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data, isError) => {
|
||||
if (config.enableLogs) {
|
||||
let percentage = 50;
|
||||
if (data.includes("Compiling")) percentage = 60;
|
||||
if (data.includes("Finished")) percentage = 80;
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `cargo: ${data.substring(0, 100)}`,
|
||||
percentage,
|
||||
logs: [data],
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (buildResult.exitCode !== 0) {
|
||||
throw new Error(`cargo build failed: ${buildResult.stderr}`);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Rust project built successfully",
|
||||
percentage: 80,
|
||||
logs: config.enableLogs ? ["✓ Build completed"] : undefined,
|
||||
});
|
||||
|
||||
// Determinar nome do binário
|
||||
const binaryName = config.packageName || config.mcpId;
|
||||
const binaryPath = path.join(
|
||||
projectPath,
|
||||
"target",
|
||||
"release",
|
||||
process.platform === "win32" ? `${binaryName}.exe` : binaryName
|
||||
);
|
||||
|
||||
const environment: MCPEnvironment = {
|
||||
type: "rust",
|
||||
path: projectPath,
|
||||
executable: binaryPath,
|
||||
args: [],
|
||||
};
|
||||
|
||||
return environment;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Rust installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-51
@@ -17,6 +17,7 @@ export interface SyncResult {
|
||||
|
||||
export class MCPSyncService {
|
||||
private static isSyncingFlag = false;
|
||||
private static syncPromise: Promise<SyncResult> | null = null;
|
||||
private static abortController: AbortController | null = null;
|
||||
private static currentProgress: SyncProgress | null = null;
|
||||
|
||||
@@ -26,63 +27,75 @@ export class MCPSyncService {
|
||||
static async syncAll(
|
||||
onProgress?: (progress: SyncProgress) => void
|
||||
): Promise<SyncResult> {
|
||||
// Se já existe um sync em andamento, retornar a promise existente
|
||||
if (this.syncPromise) {
|
||||
console.log("⏳ Sync already in progress, waiting for completion...");
|
||||
return this.syncPromise;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const errors: string[] = [];
|
||||
let totalDownloaded = 0;
|
||||
|
||||
try {
|
||||
if (this.isSyncingFlag) {
|
||||
throw new Error("Sync already in progress");
|
||||
// Criar a promise de sync e armazená-la
|
||||
this.syncPromise = (async () => {
|
||||
try {
|
||||
if (this.isSyncingFlag) {
|
||||
throw new Error("Sync already in progress");
|
||||
}
|
||||
|
||||
this.isSyncingFlag = true;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
console.log("🔄 Starting MCP marketplace sync...");
|
||||
|
||||
// Limpar cache antigo antes de começar
|
||||
MCPCacheRepository.clearCache();
|
||||
|
||||
const allMCPs = await this.downloadAllPages(onProgress);
|
||||
totalDownloaded = allMCPs.length;
|
||||
|
||||
// Salvar todos os MCPs no banco
|
||||
if (allMCPs.length > 0) {
|
||||
MCPCacheRepository.saveBatch(allMCPs);
|
||||
|
||||
// Atualizar metadados
|
||||
MCPCacheRepository.updateMetadata("last_sync", Date.now().toString());
|
||||
MCPCacheRepository.updateMetadata(
|
||||
"total_items",
|
||||
allMCPs.length.toString()
|
||||
);
|
||||
|
||||
console.log(`✅ Sync completed: ${totalDownloaded} MCPs downloaded`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return {
|
||||
success: true,
|
||||
totalDownloaded,
|
||||
errors,
|
||||
duration,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ Sync failed:", error);
|
||||
errors.push(error.message);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return {
|
||||
success: false,
|
||||
totalDownloaded,
|
||||
errors,
|
||||
duration,
|
||||
};
|
||||
} finally {
|
||||
this.isSyncingFlag = false;
|
||||
this.abortController = null;
|
||||
this.currentProgress = null;
|
||||
this.syncPromise = null; // Limpar a promise compartilhada
|
||||
}
|
||||
})();
|
||||
|
||||
this.isSyncingFlag = true;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
console.log("🔄 Starting MCP marketplace sync...");
|
||||
|
||||
// Limpar cache antigo antes de começar
|
||||
MCPCacheRepository.clearCache();
|
||||
|
||||
const allMCPs = await this.downloadAllPages(onProgress);
|
||||
totalDownloaded = allMCPs.length;
|
||||
|
||||
// Salvar todos os MCPs no banco
|
||||
if (allMCPs.length > 0) {
|
||||
MCPCacheRepository.saveBatch(allMCPs);
|
||||
|
||||
// Atualizar metadados
|
||||
MCPCacheRepository.updateMetadata("last_sync", Date.now().toString());
|
||||
MCPCacheRepository.updateMetadata(
|
||||
"total_items",
|
||||
allMCPs.length.toString()
|
||||
);
|
||||
|
||||
console.log(`✅ Sync completed: ${totalDownloaded} MCPs downloaded`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return {
|
||||
success: true,
|
||||
totalDownloaded,
|
||||
errors,
|
||||
duration,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ Sync failed:", error);
|
||||
errors.push(error.message);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return {
|
||||
success: false,
|
||||
totalDownloaded,
|
||||
errors,
|
||||
duration,
|
||||
};
|
||||
} finally {
|
||||
this.isSyncingFlag = false;
|
||||
this.abortController = null;
|
||||
this.currentProgress = null;
|
||||
}
|
||||
return this.syncPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Serviço de validação de protocolo MCP (JSON-RPC)
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import type {
|
||||
MCPEnvironment,
|
||||
ValidationResult,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
import { MCP_CONFIG } from "@/lib/config/mcp";
|
||||
|
||||
export class MCPValidatorService {
|
||||
/**
|
||||
* Valida se um servidor MCP está funcionando corretamente
|
||||
* Testa o protocolo JSON-RPC do MCP
|
||||
*/
|
||||
static async validateMCPServer(
|
||||
environment: MCPEnvironment,
|
||||
config: any,
|
||||
timeoutMs: number = MCP_CONFIG.testTimeout
|
||||
): Promise<ValidationResult> {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
let hasResolved = false;
|
||||
|
||||
// Preparar comando e argumentos
|
||||
const command = environment.executable;
|
||||
const args = environment.args || [];
|
||||
const env = {
|
||||
...process.env,
|
||||
...environment.env,
|
||||
...(config.env || {}),
|
||||
};
|
||||
|
||||
console.log(`🧪 Testing MCP server: ${command} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
// Spawnar processo do servidor MCP
|
||||
const serverProcess = spawn(command, args, {
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const responses: any[] = [];
|
||||
let currentBuffer = "";
|
||||
|
||||
// Timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
serverProcess.kill();
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Timeout after ${timeoutMs}ms. Server did not respond.`,
|
||||
});
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Capturar stdout (respostas JSON-RPC)
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
currentBuffer += text;
|
||||
|
||||
// Processar respostas JSON-RPC linha por linha
|
||||
const lines = currentBuffer.split("\n");
|
||||
currentBuffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const response = JSON.parse(trimmed);
|
||||
responses.push(response);
|
||||
console.log(
|
||||
"📨 MCP Response:",
|
||||
JSON.stringify(response).substring(0, 200)
|
||||
);
|
||||
|
||||
// Verificar se recebemos resposta válida de initialize
|
||||
if (
|
||||
response.id === 1 &&
|
||||
response.result &&
|
||||
response.result.capabilities
|
||||
) {
|
||||
// Protocolo MCP detectado, agora listar ferramentas
|
||||
this.sendRequest(serverProcess, {
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/list",
|
||||
id: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Verificar se recebemos lista de ferramentas
|
||||
if (response.id === 2 && response.result) {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
serverProcess.kill();
|
||||
|
||||
const tools = Array.isArray(response.result.tools)
|
||||
? response.result.tools.map((tool: any) => ({
|
||||
name: tool.name || "unnamed",
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
: [];
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
protocol: "mcp",
|
||||
tools,
|
||||
capabilities: responses[0]?.result?.capabilities,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Não é JSON válido, ignorar
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// Error handler
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Failed to spawn server: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Exit handler
|
||||
serverProcess.on("exit", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
|
||||
// Se saiu muito rápido sem responder, é um erro
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 1000) {
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Server exited quickly with code ${code}. stderr: ${stderr}`,
|
||||
});
|
||||
} else {
|
||||
// Servidor pode ter processado e saído normalmente
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Server exited with code ${code} before completing validation`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aguardar servidor estar pronto (pequeno delay)
|
||||
setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
// Enviar requisição de initialize
|
||||
this.sendRequest(serverProcess, {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: "ollahub",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
id: 1,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Validation error: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia requisição JSON-RPC para o servidor
|
||||
*/
|
||||
private static sendRequest(process: any, request: any): void {
|
||||
try {
|
||||
const requestStr = JSON.stringify(request) + "\n";
|
||||
console.log("📤 Sending MCP request:", requestStr.trim());
|
||||
process.stdin?.write(requestStr);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to send request:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validação simples de servidor (apenas verifica se inicia)
|
||||
* Usado como fallback se validação JSON-RPC falhar
|
||||
*/
|
||||
static async simpleValidation(
|
||||
environment: MCPEnvironment,
|
||||
config: any,
|
||||
timeoutMs: number = 5000
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const command = environment.executable;
|
||||
const args = environment.args || [];
|
||||
const env = {
|
||||
...process.env,
|
||||
...environment.env,
|
||||
...(config.env || {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const serverProcess = spawn(command, args, {
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let hasResolved = false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
serverProcess.kill();
|
||||
// Se chegou ao timeout sem errar, provavelmente está rodando
|
||||
resolve({ success: true });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to start: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Exited with code ${code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Validation error: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -61,10 +61,20 @@ export class PulseMCPService {
|
||||
|
||||
/**
|
||||
* Buscar todos os servidores MCP na API PulseMCP (sem parâmetros)
|
||||
* Com retry automático e backoff exponencial para rate limiting
|
||||
*/
|
||||
static async getAllServers(): Promise<PulseMCPResponse> {
|
||||
static async getAllServers(retryCount = 0): Promise<PulseMCPResponse> {
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 2000; // 2 segundos base
|
||||
|
||||
try {
|
||||
console.log("📡 Fetching all servers from PulseMCP API...");
|
||||
if (retryCount > 0) {
|
||||
console.log(
|
||||
`📡 Retry ${retryCount}/${maxRetries} - Fetching from PulseMCP API...`
|
||||
);
|
||||
} else {
|
||||
console.log("📡 Fetching all servers from PulseMCP API...");
|
||||
}
|
||||
|
||||
const response = await fetch(`${PULSEMCP_API_BASE}/servers`, {
|
||||
method: "GET",
|
||||
@@ -77,6 +87,19 @@ export class PulseMCPService {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Se for 429 (Too Many Requests), tentar novamente com backoff
|
||||
if (response.status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount); // Backoff exponencial
|
||||
console.warn(
|
||||
`⚠️ Rate limit hit (429). Waiting ${delay}ms before retry ${
|
||||
retryCount + 1
|
||||
}/${maxRetries}...`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.getAllServers(retryCount + 1);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`PulseMCP API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
@@ -89,9 +112,26 @@ export class PulseMCPService {
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Se for erro de rate limit e ainda temos retries, tentar novamente
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("429") &&
|
||||
retryCount < maxRetries
|
||||
) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.warn(
|
||||
`⚠️ Rate limit error. Waiting ${delay}ms before retry ${
|
||||
retryCount + 1
|
||||
}/${maxRetries}...`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.getAllServers(retryCount + 1);
|
||||
}
|
||||
|
||||
console.error("❌ Error calling PulseMCP API:", error);
|
||||
throw new Error(
|
||||
`Failed to fetch MCPs: ${
|
||||
`Failed to fetch MCPs after ${retryCount + 1} attempts: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Tipos e interfaces para o sistema de instalação MCP
|
||||
*/
|
||||
|
||||
export type PackageRegistry = "npm" | "pypi" | "cargo" | "other" | null;
|
||||
|
||||
export enum MCPInstallationStatus {
|
||||
PENDING = "pending",
|
||||
CHECKING_DEPENDENCIES = "checking_dependencies",
|
||||
DOWNLOADING = "downloading",
|
||||
INSTALLING = "installing",
|
||||
TESTING = "testing",
|
||||
READY = "ready",
|
||||
FAILED = "failed",
|
||||
}
|
||||
|
||||
export interface InstallationProgress {
|
||||
status: MCPInstallationStatus;
|
||||
message: string;
|
||||
percentage: number;
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
export interface MCPEnvironment {
|
||||
type: "npm" | "python" | "rust";
|
||||
path: string;
|
||||
executable: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
protocol: "mcp" | "unknown";
|
||||
tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: any;
|
||||
}>;
|
||||
resources?: Array<{
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
error?: string;
|
||||
capabilities?: any;
|
||||
}
|
||||
|
||||
export interface SystemDependencies {
|
||||
node: boolean;
|
||||
npm: boolean;
|
||||
python: boolean;
|
||||
pip: boolean;
|
||||
cargo: boolean;
|
||||
git: boolean;
|
||||
}
|
||||
|
||||
export interface InstallationConfig {
|
||||
mcpId: string;
|
||||
packageName: string;
|
||||
packageRegistry: PackageRegistry;
|
||||
repositoryUrl?: string;
|
||||
config: any;
|
||||
enableLogs: boolean;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export interface MCPProvider {
|
||||
id: string; // formato: "owner/repo"
|
||||
originalId?: string; // ID original do cache antes de transformar para owner/repo
|
||||
owner?: string; // Owner do repositório
|
||||
repo?: string; // Nome do repositório
|
||||
name: string; // mapeado de content_name
|
||||
author: string; // mapeado de publisher_id
|
||||
description: string;
|
||||
@@ -97,6 +100,19 @@ export interface PulseMCPResponse {
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseTime?: number; // em ms
|
||||
serverVersion?: string;
|
||||
availableTools?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MCPTestRequest {
|
||||
mcpId: string;
|
||||
}
|
||||
|
||||
export interface MCPServerMetadata {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Utilitários para operações de sistema
|
||||
*/
|
||||
|
||||
import { exec, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Verifica se um comando existe no sistema
|
||||
*/
|
||||
export async function commandExists(command: string): Promise<boolean> {
|
||||
try {
|
||||
const isWindows = process.platform === "win32";
|
||||
const checkCommand = isWindows ? "where" : "which";
|
||||
|
||||
await execAsync(`${checkCommand} ${command}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa um comando e retorna stdout/stderr
|
||||
*/
|
||||
export async function execCommand(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
try {
|
||||
const fullCommand = `${cmd} ${args.join(" ")}`;
|
||||
const { stdout, stderr } = await execAsync(fullCommand, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
||||
timeout: options?.timeout || 300000, // 5 minutos default
|
||||
});
|
||||
|
||||
return { stdout, stderr, exitCode: 0 };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
stdout: error.stdout || "",
|
||||
stderr: error.stderr || error.message,
|
||||
exitCode: error.code || 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawna um processo e captura output com progresso
|
||||
*/
|
||||
export async function spawnWithProgress(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
onOutput?: (data: string, isError: boolean) => void;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let hasTimedOut = false;
|
||||
|
||||
const childProcess = spawn(cmd, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ? { ...process.env, ...options.env } : process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Timeout handler
|
||||
const timeout = options.timeout || 300000; // 5 minutos default
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasTimedOut = true;
|
||||
childProcess.kill("SIGTERM");
|
||||
|
||||
// Force kill após 5 segundos
|
||||
setTimeout(() => {
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}, timeout);
|
||||
|
||||
// Capturar stdout
|
||||
childProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
if (options.onOutput) {
|
||||
options.onOutput(text, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
childProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
if (options.onOutput) {
|
||||
options.onOutput(text, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Error handler
|
||||
childProcess.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to spawn process: ${error.message}`));
|
||||
});
|
||||
|
||||
// Exit handler
|
||||
childProcess.on("close", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (hasTimedOut) {
|
||||
reject(
|
||||
new Error(
|
||||
`Process timed out after ${timeout}ms. stdout: ${stdout}, stderr: ${stderr}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve({
|
||||
exitCode: code || 0,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiza nome de pacote para evitar command injection
|
||||
*/
|
||||
export function sanitizePackageName(packageName: string): string {
|
||||
// Remove caracteres perigosos, mantém apenas alphanumeric, @, /, -, _
|
||||
return packageName.replace(/[^a-zA-Z0-9@/\-_.]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se um caminho está dentro de um diretório base (previne path traversal)
|
||||
*/
|
||||
export function isPathSafe(basePath: string, targetPath: string): boolean {
|
||||
const path = require("path");
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
|
||||
return resolvedTarget.startsWith(resolvedBase);
|
||||
}
|
||||
Reference in new issue
Block a user