Files
automaker/libs/platform/src/node-finder.ts
Kacper 887fb93b3b fix: address additional code review feedback
- Add path.normalize() for Windows mixed separator handling
- Add validation to check Node executable exists after finding it
- Improve error dialog with specific troubleshooting advice for Node.js
  related errors vs general errors
- Include source info in validation error message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 14:54:26 +01:00

347 lines
9.3 KiB
TypeScript

/**
* 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.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
/** 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;
}
/**
* Find Node.js executable from version manager directories (NVM, fnm)
* Uses semantic version sorting to prefer the latest version
*/
function findNodeFromVersionManager(
basePath: string,
binSubpath: string = 'bin/node'
): string | null {
if (!fs.existsSync(basePath)) return null;
try {
const versions = fs
.readdirSync(basePath)
.filter((v) => /^v?\d+/.test(v))
// Semantic version sort - newest first using localeCompare with numeric option
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
for (const version of versions) {
const nodePath = path.join(basePath, version, binSubpath);
if (fs.existsSync(nodePath)) {
return nodePath;
}
}
} catch {
// Directory read failed, skip this location
}
return null;
}
/**
* Find Node.js on macOS
*/
function findNodeMacOS(homeDir: string): NodeFinderResult | null {
// Check Homebrew paths in order of preference
const homebrewPaths = [
// Apple Silicon
'/opt/homebrew/bin/node',
// Intel
'/usr/local/bin/node',
];
for (const nodePath of homebrewPaths) {
if (fs.existsSync(nodePath)) {
return { nodePath, source: 'homebrew' };
}
}
// System Node
if (fs.existsSync('/usr/bin/node')) {
return { nodePath: '/usr/bin/node', source: 'system' };
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
// fnm installation (multiple possible locations)
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
];
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 {
// Common Linux paths
const systemPaths = [
'/usr/bin/node',
'/usr/local/bin/node',
// Snap installation
'/snap/bin/node',
];
for (const nodePath of systemPaths) {
if (fs.existsSync(nodePath)) {
return { nodePath, source: 'system' };
}
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
// fnm installation
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, '.fnm', 'node-versions'),
];
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 programFilesPaths = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'),
];
for (const nodePath of programFilesPaths) {
if (fs.existsSync(nodePath)) {
return { nodePath, source: 'program-files' };
}
}
// NVM for Windows
const nvmWindowsPath = path.join(
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
'nvm'
);
const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe');
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm-windows' };
}
// fnm on Windows (prioritize canonical installation path over shell shims)
const fnmWindowsPaths = [
path.join(homeDir, '.fnm', 'node-versions'),
path.join(
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
'fnm',
'node-versions'
),
];
for (const fnmBasePath of fnmWindowsPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe');
if (fnmNode) {
return { nodePath: fnmNode, source: 'fnm' };
}
}
// Scoop installation
const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
if (fs.existsSync(scoopPath)) {
return { nodePath: scoopPath, source: 'scoop' };
}
// Chocolatey installation
const chocoPath = path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'node.exe'
);
if (fs.existsSync(chocoPath)) {
return { nodePath: chocoPath, source: 'chocolatey' };
}
return null;
}
/**
* Try to find Node.js using shell commands (which/where)
*/
function findNodeViaShell(platform: NodeJS.Platform): 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];
if (nodePath && fs.existsSync(nodePath)) {
return {
nodePath,
source: platform === 'win32' ? 'where' : 'which',
};
}
} catch {
// Shell command failed (likely when launched from desktop without PATH)
}
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);
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
return `${nodeDir}${path.delimiter}${currentPath}`;
}