/** * Cross-platform Node.js executable finder * * Handles finding Node.js when the app is launched from desktop environments * (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited. * * Uses centralized system-paths module for all file system access. */ import { execSync } from 'child_process'; import path from 'path'; import os from 'os'; import { systemPathExists, systemPathIsExecutable, systemPathReaddirSync, systemPathReadFileSync, getNvmPaths, getFnmPaths, getNodeSystemPaths, getScoopNodePath, getChocolateyNodePath, getWslVersionPath, } from './system-paths.js'; /** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */ const VERSION_DIR_PATTERN = /^v?\d+/; /** Pattern to identify pre-release versions (beta, rc, alpha, nightly, canary) */ const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; /** Result of finding Node.js executable */ export interface NodeFinderResult { /** Path to the Node.js executable */ nodePath: string; /** How Node.js was found */ source: | 'homebrew' | 'system' | 'nvm' | 'fnm' | 'nvm-windows' | 'program-files' | 'scoop' | 'chocolatey' | 'which' | 'where' | 'fallback'; } /** Options for finding Node.js */ export interface NodeFinderOptions { /** Skip the search and return 'node' immediately (useful for dev mode) */ skipSearch?: boolean; /** Custom logger function */ logger?: (message: string) => void; } /** * Check if a file exists and is executable * Uses centralized systemPathIsExecutable for path validation */ function isExecutable(filePath: string): boolean { try { return systemPathIsExecutable(filePath); } catch { return false; } } /** * Find Node.js executable from version manager directories (NVM, fnm) * Uses semantic version sorting to prefer the latest stable version * Pre-release versions (beta, rc, alpha) are deprioritized but used as fallback */ function findNodeFromVersionManager( basePath: string, binSubpath: string = 'bin/node' ): string | null { try { if (!systemPathExists(basePath)) return null; } catch { return null; } try { const allVersions = systemPathReaddirSync(basePath) .filter((v) => VERSION_DIR_PATTERN.test(v)) // Semantic version sort - newest first using localeCompare with numeric option .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); // Separate stable and pre-release versions, preferring stable const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); // Try stable versions first, then fall back to pre-release for (const version of [...stableVersions, ...preReleaseVersions]) { const nodePath = path.join(basePath, version, binSubpath); if (isExecutable(nodePath)) { return nodePath; } } } catch { // Directory read failed, skip this location } return null; } /** * Find Node.js on macOS */ function findNodeMacOS(_homeDir: string): NodeFinderResult | null { // Check system paths (Homebrew, system) const systemPaths = getNodeSystemPaths(); for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { // Determine source based on path if (nodePath.includes('homebrew') || nodePath === '/usr/local/bin/node') { return { nodePath, source: 'homebrew' }; } return { nodePath, source: 'system' }; } } // NVM installation const nvmPaths = getNvmPaths(); for (const nvmPath of nvmPaths) { const nvmNode = findNodeFromVersionManager(nvmPath); if (nvmNode) { return { nodePath: nvmNode, source: 'nvm' }; } } // fnm installation const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath); if (fnmNode) { return { nodePath: fnmNode, source: 'fnm' }; } } return null; } /** * Find Node.js on Linux */ function findNodeLinux(_homeDir: string): NodeFinderResult | null { // Check system paths const systemPaths = getNodeSystemPaths(); for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { return { nodePath, source: 'system' }; } } // NVM installation const nvmPaths = getNvmPaths(); for (const nvmPath of nvmPaths) { const nvmNode = findNodeFromVersionManager(nvmPath); if (nvmNode) { return { nodePath: nvmNode, source: 'nvm' }; } } // fnm installation const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath); if (fnmNode) { return { nodePath: fnmNode, source: 'fnm' }; } } return null; } /** * Find Node.js on Windows */ function findNodeWindows(_homeDir: string): NodeFinderResult | null { // Program Files paths const systemPaths = getNodeSystemPaths(); for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { return { nodePath, source: 'program-files' }; } } // NVM for Windows const nvmPaths = getNvmPaths(); for (const nvmPath of nvmPaths) { const nvmNode = findNodeFromVersionManager(nvmPath, 'node.exe'); if (nvmNode) { return { nodePath: nvmNode, source: 'nvm-windows' }; } } // fnm on Windows const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe'); if (fnmNode) { return { nodePath: fnmNode, source: 'fnm' }; } } // Scoop installation const scoopPath = getScoopNodePath(); if (isExecutable(scoopPath)) { return { nodePath: scoopPath, source: 'scoop' }; } // Chocolatey installation const chocoPath = getChocolateyNodePath(); if (isExecutable(chocoPath)) { return { nodePath: chocoPath, source: 'chocolatey' }; } return null; } /** * Try to find Node.js using shell commands (which/where) */ function findNodeViaShell( platform: NodeJS.Platform, logger: (message: string) => void = () => {} ): NodeFinderResult | null { try { const command = platform === 'win32' ? 'where node' : 'which node'; const result = execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); // 'where' on Windows can return multiple lines, take the first const nodePath = result.split(/\r?\n/)[0]; // Validate path: check for null bytes (security) and executable permission if (nodePath && !nodePath.includes('\x00') && isExecutable(nodePath)) { return { nodePath, source: platform === 'win32' ? 'where' : 'which', }; } } catch { // Shell command failed (likely when launched from desktop without PATH) logger('Shell command failed to find Node.js (expected when launched from desktop)'); } return null; } /** * Find Node.js executable - handles desktop launcher scenarios where PATH is limited * * @param options - Configuration options * @returns Result with path and source information * * @example * ```typescript * import { findNodeExecutable } from '@automaker/platform'; * * // In development, skip the search * const result = findNodeExecutable({ skipSearch: isDev }); * console.log(`Using Node.js from ${result.source}: ${result.nodePath}`); * * // Spawn a process with the found Node.js * spawn(result.nodePath, ['script.js']); * ``` */ export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderResult { const { skipSearch = false, logger = () => {} } = options; // Skip search if requested (e.g., in development mode) if (skipSearch) { return { nodePath: 'node', source: 'fallback' }; } const platform = process.platform; const homeDir = os.homedir(); // Platform-specific search let result: NodeFinderResult | null = null; switch (platform) { case 'darwin': result = findNodeMacOS(homeDir); break; case 'linux': result = findNodeLinux(homeDir); break; case 'win32': result = findNodeWindows(homeDir); break; } if (result) { logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); return result; } // Fallback - try shell resolution (works when launched from terminal) result = findNodeViaShell(platform, logger); if (result) { logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); return result; } // Ultimate fallback logger('Could not find Node.js, falling back to "node"'); return { nodePath: 'node', source: 'fallback' }; } /** * Build an enhanced PATH that includes the Node.js directory * Useful for ensuring child processes can find Node.js * * @param nodePath - Path to the Node.js executable * @param currentPath - Current PATH environment variable * @returns Enhanced PATH with Node.js directory prepended if not already present * * @example * ```typescript * import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; * * const { nodePath } = findNodeExecutable(); * const enhancedPath = buildEnhancedPath(nodePath, process.env.PATH); * * spawn(nodePath, ['script.js'], { * env: { ...process.env, PATH: enhancedPath } * }); * ``` */ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): string { // If using fallback 'node', don't modify PATH if (nodePath === 'node') { return currentPath; } const nodeDir = path.dirname(nodePath); // Don't add if already present or if it's just '.' // Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18) // Normalize paths for comparison to handle mixed separators on Windows const normalizedNodeDir = path.normalize(nodeDir); const pathSegments = currentPath.split(path.delimiter).map((s) => path.normalize(s)); if (normalizedNodeDir === '.' || pathSegments.includes(normalizedNodeDir)) { return currentPath; } // Use platform-appropriate path separator // Handle empty currentPath without adding trailing delimiter if (!currentPath) { return nodeDir; } return `${nodeDir}${path.delimiter}${currentPath}`; }