import { execSync } from 'child_process'; import path from 'path'; import semver from 'semver'; import { __logger } from '../logger/internal-logger'; import { CBFileSystem } from '../node/file-system'; import { AllowedModule, Manifest } from '../types/common'; interface PackageJson { dependencies: Record; } /** * DependencyManager handles isolated npm installations for user projects * Public implementation for managing dependencies in development environment */ export class DependencyManager { // Cache to check which dependencies are already installed for a project private installed: boolean; private fileSystem: CBFileSystem; constructor(fileSystem: CBFileSystem) { this.fileSystem = fileSystem; this.installed = false; } /** * Ensures dependencies are installed for a user project * @param {string} projectPath - Path to the user project directory * @param {Manifest} manifest - The project manifest containing dependencies * @returns {Promise} */ async ensureDependencies(projectPath: string, manifest: Manifest): Promise { if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) { __logger.debug('No dependencies to install'); return; } // Check if already installed if (this.installed) { __logger.debug('Dependencies already installed for this project'); return; } const handlerPath = path.join(projectPath, 'handler'); const packageJsonPath = path.join(handlerPath, 'package.json'); try { // Create handler directory if it doesn't exist if (!this.fileSystem.existsSync(handlerPath)) { this.fileSystem.mkdirSync(handlerPath, { recursive: true }); } // Create or update package.json await this.createPackageJson(packageJsonPath, manifest.dependencies); // Install dependencies await this.installDependencies(handlerPath); // Mark as installed this.installed = true; __logger.info(`Installed ${Object.keys(manifest.dependencies).length} dependencies for project`); } catch (error) { const err = error as Error; __logger.error('Failed to install dependencies:', err.message); throw new Error(`Dependency installation failed: ${err.message}`); } } /** * Creates a safe require function that uses isolated node_modules * @param {string} projectPath - Path to the user project * @param {AllowedModule[]} allowedModules - List of allowed modules * @returns {Function} Safe require function */ createIsolatedRequire(projectPath: string, allowedModules: AllowedModule[]): (moduleName: string) => any { const handlerPath = path.resolve(projectPath, 'handler'); const nodeModulesPath = path.join(handlerPath, 'node_modules'); return (moduleName: string) => { // Check if the module is a relative path if (moduleName.startsWith('./')) { // Normalize the path to prevent path traversal attacks return this.getRelativePathRequire(moduleName, handlerPath); } // Handle direct CommonJS paths (e.g., 'axios/dist/node/axios.cjs') if (moduleName.includes('/')) { // Extract the base module name return this.getGenericDependencyRequire(moduleName, allowedModules, nodeModulesPath); } // Verify version if constraint is specified const resolvedModulePath = this.validateAndGetModulePath(allowedModules, nodeModulesPath, moduleName); try { // Check if this is an ES module that loaded but doesn't have the expected interface // This happens when ES modules export named exports instead of a default function // Try to load the module and handle ES module issues with cjs (if available) else fallback to the ES module return this.tryLoadCommonJSAlternative(moduleName, resolvedModulePath); } catch (error) { const err = error as Error; throw new Error(`Failed to load module "${moduleName}": ${err.message}`); } }; } /** * Creates or updates package.json for the project * @param {string} packageJsonPath - Path to package.json * @param {Record} dependencies - Dependencies from manifest */ protected async createPackageJson(packageJsonPath: string, dependencies: Record): Promise { const packageJson: PackageJson = { dependencies: { ...dependencies } }; const content = JSON.stringify(packageJson, null, 2); this.fileSystem.writeFileSync(packageJsonPath, content); __logger.debug('Created/updated package.json'); } /** * Installs dependencies using npm * @param {string} handlerPath - Path to the handler directory */ protected async installDependencies(handlerPath: string): Promise { try { execSync('npm install --ignore-scripts --no-audit --no-fund --loglevel=error', { cwd: handlerPath, stdio: 'pipe', timeout: 60000 // 60 second timeout }); __logger.debug('npm install completed successfully'); } catch (error) { const err = error as Error; throw new Error(`npm install failed: ${err.message}`); } } /** * Tries to load a CommonJS alternative for an ES module. This is a fallback for when the common JS is not the default export. * @param moduleName - Name of the module to require * @param resolvedModulePath - Path to the resolved module * @returns */ protected tryLoadCommonJSAlternative(moduleName: string, resolvedModulePath: string): any { const module = require(resolvedModulePath); if (module && typeof module === 'object' && module.__esModule && typeof module !== 'function') { __logger.debug(`ES module detected for ${moduleName} (loaded but has named exports), looking for CommonJS alternative`); const packageJsonPath = path.join(resolvedModulePath, 'package.json'); if (this.fileSystem.existsSync(packageJsonPath)) { const packageJson = JSON.parse(this.fileSystem.readFileSync(packageJsonPath, 'utf8')); // Try to find CommonJS export from package.json exports if (packageJson.exports && packageJson.exports['.']) { const exports = packageJson.exports['.']; // Try direct require field if (exports.require) { const cjsPath = path.resolve(resolvedModulePath, exports.require); __logger.debug(`Trying CommonJS path: ${exports.require}`); return require(cjsPath); } // Try nested default.require (like axios) if (exports.default && exports.default.require) { const cjsPath = path.resolve(resolvedModulePath, exports.default.require); __logger.debug(`Trying default CommonJS path: ${exports.default.require}`); return require(cjsPath); } } // Try to find CommonJS entry point from main field if (packageJson.main) { const mainPath = path.resolve(resolvedModulePath, packageJson.main); __logger.debug(`Trying main field: ${packageJson.main}`); return require(mainPath); } } // If we can't find a CommonJS alternative, return the ES module as-is __logger.error(`No CommonJS alternative found for ${moduleName}, using ES module as-is`); return module; } return module } /** * Gets the relative path require. * @param moduleName - Name of the module to require * @param handlerPath - Path to the handler directory * @returns */ protected getRelativePathRequire(moduleName: string, handlerPath: string) { const normalizedPath = path.normalize(moduleName); // Check for any path traversal attempts after normalization if (normalizedPath.includes('..')) { throw new Error('Path traversal not allowed in relative imports'); } // Resolve the path relative to handlerPath const resolvedPath = path.resolve(handlerPath, normalizedPath); // Validate that the resolved path is within the handlerPath directory if (!resolvedPath.startsWith(handlerPath)) { throw new Error('Relative import path resolves outside of handler directory'); } // Check if the file exists and determine the correct path to require let finalPath = resolvedPath; if (!this.fileSystem.existsSync(resolvedPath)) { const jsPath = resolvedPath + ".js"; if (this.fileSystem.existsSync(jsPath)) { finalPath = jsPath; // Use the .js path that actually exists } else { throw new Error(`File or module not found: ${moduleName}`); } } return require(finalPath); } /** * Gets the generic dependency require. Unless there are special cases like ES modules, this should work for all dependencies. * @param moduleName - Name of the module to require * @param allowedModules - List of allowed modules * @param nodeModulesPath - Path to the node_modules directory * @returns */ protected getGenericDependencyRequire(moduleName: string, allowedModules: AllowedModule[], nodeModulesPath: string) { const baseModuleName: string | undefined = moduleName.split('/')[0]; // Check if the base module is allowed const allowedModule = allowedModules.find(module => module.name === baseModuleName); if (!allowedModule) { throw new Error(`Module "${baseModuleName}" is not allowed in this environment`); } // Check if the full path exists in project's node_modules const fullModulePath = path.join(nodeModulesPath, moduleName); if (!this.fileSystem.existsSync(fullModulePath)) { throw new Error(`Module path "${moduleName}" is not installed. Please ensure dependencies are installed.`); } __logger.debug(`Loading CommonJS path: ${moduleName}`); return require(path.resolve(fullModulePath)); } /** * Validates and gets the resolved path to the module. * @param allowedModules - List of allowed modules * @param nodeModulesPath - Path to the node_modules directory * @param moduleName - Name of the module to validate * @returns Path to the module */ protected validateAndGetModulePath(allowedModules: AllowedModule[], nodeModulesPath: string, moduleName: string): string { // Check if module is installed in project's node_modules const modulePath = path.join(nodeModulesPath, moduleName); if (!this.fileSystem.existsSync(modulePath)) { throw new Error(`Module "${moduleName}" is not installed. Please ensure dependencies are installed.`); } // Check if module is allowed const allowedModule = allowedModules.find(module => module.name === moduleName); if (!allowedModule) { throw new Error(`Module "${moduleName}" is not allowed in this environment`); } if (allowedModule.version) { try { const packageJsonPath = path.join(modulePath, 'package.json'); const packageJson = JSON.parse(this.fileSystem.readFileSync(packageJsonPath, 'utf8')); const installedVersion = packageJson.version; if (!semver.satisfies(installedVersion, allowedModule.version)) { throw new Error(`Module "${moduleName}" version ${installedVersion} does not satisfy allowed version constraint ${allowedModule.version}`); } __logger.debug(`Module "${moduleName}" version ${installedVersion} satisfies constraint ${allowedModule.version}`); } catch (error) { const err = error as Error; __logger.warn(`Warning: Could not verify version for module "${moduleName}":`, err.message); } } else { throw new Error(`Configuration error: Allowed module "${moduleName}" does not have a version constraint`); } // Fallback to the module path, might or might not work for some dependencies. return path.resolve(modulePath); } }