mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- 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>
347 lines
9.3 KiB
TypeScript
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}`;
|
|
}
|