From 16a92096b3a12c89715c9a656a20fb5b1b17760f Mon Sep 17 00:00:00 2001 From: samanhappy Date: Mon, 13 Oct 2025 22:36:29 +0800 Subject: [PATCH] feat: Enhance package root detection and version retrieval using ESM-compatible methods (#371) --- src/server.ts | 72 ++++++---------- src/utils/moduleDir.ts | 11 +++ src/utils/path.ts | 191 +++++++++++++++++++++++++++++++++++++++-- src/utils/version.ts | 15 +++- tests/setup.ts | 5 ++ 5 files changed, 236 insertions(+), 58 deletions(-) create mode 100644 src/utils/moduleDir.ts diff --git a/src/server.ts b/src/server.ts index 04a21ea..2686347 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,9 +15,26 @@ import { } from './services/sseService.js'; import { initializeDefaultUser } from './models/User.js'; import { sseUserContextMiddleware } from './middlewares/userContext.js'; +import { findPackageRoot } from './utils/path.js'; +import { getCurrentModuleDir } from './utils/moduleDir.js'; -// Get the current working directory (will be project root in most cases) -const currentFileDir = process.cwd() + '/src'; +/** + * Get the directory of the current module + * This is wrapped in a function to allow easy mocking in test environments + */ +function getCurrentFileDir(): string { + // In test environments, use process.cwd() to avoid import.meta issues + if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) { + return process.cwd(); + } + + try { + return getCurrentModuleDir(); + } catch { + // Fallback for environments where import.meta might not be available + return process.cwd(); + } +} export class AppServer { private app: express.Application; @@ -167,10 +184,11 @@ export class AppServer { private findFrontendDistPath(): string | null { // Debug flag for detailed logging const debug = process.env.DEBUG === 'true'; + const currentDir = getCurrentFileDir(); if (debug) { console.log('DEBUG: Current directory:', process.cwd()); - console.log('DEBUG: Script directory:', currentFileDir); + console.log('DEBUG: Script directory:', currentDir); } // First, find the package root directory @@ -205,51 +223,9 @@ export class AppServer { // Helper method to find the package root (where package.json is located) private findPackageRoot(): string | null { - const debug = process.env.DEBUG === 'true'; - - // Possible locations for package.json - const possibleRoots = [ - // Standard npm package location - path.resolve(currentFileDir, '..', '..'), - // Current working directory - process.cwd(), - // When running from dist directory - path.resolve(currentFileDir, '..'), - // When installed via npx - path.resolve(currentFileDir, '..', '..', '..'), - ]; - - // Special handling for npx - if (process.argv[1] && process.argv[1].includes('_npx')) { - const npxDir = path.dirname(process.argv[1]); - possibleRoots.unshift(path.resolve(npxDir, '..')); - } - - if (debug) { - console.log('DEBUG: Checking for package.json in:', possibleRoots); - } - - for (const root of possibleRoots) { - const packageJsonPath = path.join(root, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') { - if (debug) { - console.log(`DEBUG: Found package.json at ${packageJsonPath}`); - } - return root; - } - } catch (e) { - if (debug) { - console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e); - } - // Continue to the next potential root - } - } - } - - return null; + // Use the shared utility function which properly handles ESM module paths + const currentDir = getCurrentFileDir(); + return findPackageRoot(currentDir); } } diff --git a/src/utils/moduleDir.ts b/src/utils/moduleDir.ts new file mode 100644 index 0000000..0b72dd1 --- /dev/null +++ b/src/utils/moduleDir.ts @@ -0,0 +1,11 @@ +import { fileURLToPath } from 'url'; +import path from 'path'; + +/** + * Get the directory of the current module + * This is in a separate file to allow mocking in test environments + */ +export function getCurrentModuleDir(): string { + const currentModuleFile = fileURLToPath(import.meta.url); + return path.dirname(currentModuleFile); +} diff --git a/src/utils/path.ts b/src/utils/path.ts index 27c6055..e6a670a 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,10 +1,171 @@ import fs from 'fs'; import path from 'path'; import { dirname } from 'path'; +import { getCurrentModuleDir } from './moduleDir.js'; // Project root directory - use process.cwd() as a simpler alternative const rootDir = process.cwd(); +// Cache the package root for performance +let cachedPackageRoot: string | null | undefined = undefined; + +/** + * Initialize package root by trying to find it using the module directory + * This should be called when the module is first loaded + */ +function initializePackageRoot(): void { + // Skip initialization in test environments + if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) { + return; + } + + try { + // Try to get the current module's directory + const currentModuleDir = getCurrentModuleDir(); + + // This file is in src/utils/path.ts (or dist/utils/path.js when compiled) + // So package.json should be 2 levels up + const possibleRoots = [ + path.resolve(currentModuleDir, '..', '..'), // dist -> package root + path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root + ]; + + for (const root of possibleRoots) { + const packageJsonPath = path.join(root, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') { + cachedPackageRoot = root; + return; + } + } catch { + // Continue checking + } + } + } + } catch { + // If initialization fails, cachedPackageRoot remains undefined + // and findPackageRoot will search normally + } +} + +// Initialize on module load (unless in test environment) +initializePackageRoot(); + +/** + * Find the package root directory (where package.json is located) + * This works correctly when the package is installed globally or locally + * @param startPath Starting path to search from (defaults to checking module paths) + * @returns The package root directory path, or null if not found + */ +export const findPackageRoot = (startPath?: string): string | null => { + // Return cached value if available and no specific start path is requested + if (cachedPackageRoot !== undefined && !startPath) { + return cachedPackageRoot; + } + + const debug = process.env.DEBUG === 'true'; + + // Possible locations for package.json relative to the search path + const possibleRoots: string[] = []; + + if (startPath) { + // When start path is provided (from fileURLToPath(import.meta.url)) + possibleRoots.push( + // When in dist/utils (compiled code) - go up 2 levels + path.resolve(startPath, '..', '..'), + // When in dist/ (compiled code) - go up 1 level + path.resolve(startPath, '..'), + // Direct parent directories + path.resolve(startPath) + ); + } + + // Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire) + try { + // In ESM, we can use import.meta.resolve, but it's async in some versions + // So we'll try to find the module by checking the node_modules structure + + // Check if this file is in a node_modules installation + const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1]; + if (currentFile) { + const nodeModulesIndex = currentFile.indexOf('node_modules'); + if (nodeModulesIndex !== -1) { + // Extract the package path from node_modules + const afterNodeModules = currentFile.substring(nodeModulesIndex + 'node_modules'.length + 1); + const packageNameEnd = afterNodeModules.indexOf(path.sep); + if (packageNameEnd !== -1) { + const packagePath = currentFile.substring(0, nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd); + possibleRoots.push(packagePath); + } + } + } + } catch { + // Ignore errors + } + + // Check module.filename location (works in Node.js when available) + if (typeof __filename !== 'undefined') { + const moduleDir = path.dirname(__filename); + possibleRoots.push( + path.resolve(moduleDir, '..', '..'), + path.resolve(moduleDir, '..') + ); + } + + // Check common installation locations + possibleRoots.push( + // Current working directory (for development/tests) + process.cwd(), + // Parent of cwd + path.resolve(process.cwd(), '..') + ); + + if (debug) { + console.log('DEBUG: Searching for package.json from:', startPath || 'multiple locations'); + console.log('DEBUG: Checking paths:', possibleRoots); + } + + // Remove duplicates + const uniqueRoots = [...new Set(possibleRoots)]; + + for (const root of uniqueRoots) { + const packageJsonPath = path.join(root, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') { + if (debug) { + console.log(`DEBUG: Found package.json at ${packageJsonPath}`); + } + // Cache the result if no specific start path was requested + if (!startPath) { + cachedPackageRoot = root; + } + return root; + } + } catch (e) { + // Continue to the next potential root + if (debug) { + console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e); + } + } + } + } + + if (debug) { + console.warn('DEBUG: Could not find package root directory'); + } + + // Cache null result as well to avoid repeated searches + if (!startPath) { + cachedPackageRoot = null; + } + + return null; +}; + function getParentPath(p: string, filename: string): string { if (p.endsWith(filename)) { p = p.slice(0, -filename.length); @@ -40,22 +201,36 @@ export const getConfigFilePath = (filename: string, description = 'Configuration } const potentialPaths = [ - ...[ - // Prioritize process.cwd() as the first location to check - path.resolve(process.cwd(), filename), - // Use path relative to the root directory - path.join(rootDir, filename), - // If installed with npx, may need to look one level up - path.join(dirname(rootDir), filename), - ], + // Prioritize process.cwd() as the first location to check + path.resolve(process.cwd(), filename), + // Use path relative to the root directory + path.join(rootDir, filename), + // If installed with npx, may need to look one level up + path.join(dirname(rootDir), filename), ]; + // Also check in the installed package root directory + const packageRoot = findPackageRoot(); + if (packageRoot) { + potentialPaths.push(path.join(packageRoot, filename)); + } + for (const filePath of potentialPaths) { if (fs.existsSync(filePath)) { return filePath; } } + // If all paths do not exist, check if we have a fallback in the package root + // If the file exists in the package root, use it as the default + if (packageRoot) { + const packageConfigPath = path.join(packageRoot, filename); + if (fs.existsSync(packageConfigPath)) { + console.log(`Using ${description} from package: ${packageConfigPath}`); + return packageConfigPath; + } + } + // If all paths do not exist, use default path // Using the default path is acceptable because it ensures the application can proceed // even if the configuration file is missing. This fallback is particularly useful in diff --git a/src/utils/version.ts b/src/utils/version.ts index 7289de1..19a2fc8 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,13 +1,24 @@ import fs from 'fs'; import path from 'path'; +import { findPackageRoot } from './path.js'; /** * Gets the package version from package.json + * @param searchPath Optional path to start searching from (defaults to cwd) * @returns The version string from package.json, or 'dev' if not found */ -export const getPackageVersion = (): string => { +export const getPackageVersion = (searchPath?: string): string => { try { - const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + // Use provided path or fallback to current working directory + const startPath = searchPath || process.cwd(); + + const packageRoot = findPackageRoot(startPath); + if (!packageRoot) { + console.warn('Could not find package root, using default version'); + return 'dev'; + } + + const packageJsonPath = path.join(packageRoot, 'package.json'); const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); const packageJson = JSON.parse(packageJsonContent); return packageJson.version || 'dev'; diff --git a/tests/setup.ts b/tests/setup.ts index fafc622..83ae455 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -8,6 +8,11 @@ Object.assign(process.env, { DATABASE_URL: 'sqlite::memory:', }); +// Mock moduleDir to avoid import.meta parsing issues in Jest +jest.mock('../src/utils/moduleDir.js', () => ({ + getCurrentModuleDir: jest.fn(() => process.cwd()), +})); + // Global test utilities declare global { // eslint-disable-next-line @typescript-eslint/no-namespace