mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
fix: add cross-platform Node.js executable finder for desktop launches
When the Electron app is launched from desktop environments (macOS Finder, Windows Explorer, Linux desktop launchers), the PATH environment variable is often limited and doesn't include Node.js installation paths. This adds a new `findNodeExecutable()` utility to @automaker/platform that: - Searches common installation paths (Homebrew, system, Program Files) - Supports version managers: NVM, fnm, nvm-windows, Scoop, Chocolatey - Falls back to shell resolution (which/where) when available - Enhances PATH for child processes via `buildEnhancedPath()` - Works cross-platform: macOS, Windows, and Linux 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,3 +44,11 @@ export {
|
||||
|
||||
// Secure file system (validates paths before I/O operations)
|
||||
export * as secureFs from './secure-fs.js';
|
||||
|
||||
// Node.js executable finder (cross-platform)
|
||||
export {
|
||||
findNodeExecutable,
|
||||
buildEnhancedPath,
|
||||
type NodeFinderResult,
|
||||
type NodeFinderOptions,
|
||||
} from './node-finder.js';
|
||||
|
||||
341
libs/platform/src/node-finder.ts
Normal file
341
libs/platform/src/node-finder.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 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
|
||||
const fnmWindowsPaths = [
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
||||
'fnm_multishells'
|
||||
),
|
||||
path.join(homeDir, '.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 '.'
|
||||
if (nodeDir === '.' || currentPath.includes(nodeDir)) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
// Use platform-appropriate path separator
|
||||
return `${nodeDir}${path.delimiter}${currentPath}`;
|
||||
}
|
||||
115
libs/platform/tests/node-finder.test.ts
Normal file
115
libs/platform/tests/node-finder.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js';
|
||||
import path from 'path';
|
||||
|
||||
describe('node-finder', () => {
|
||||
describe('findNodeExecutable', () => {
|
||||
it("should return 'node' with fallback source when skipSearch is true", () => {
|
||||
const result = findNodeExecutable({ skipSearch: true });
|
||||
|
||||
expect(result.nodePath).toBe('node');
|
||||
expect(result.source).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should call logger when node is found', () => {
|
||||
const logger = vi.fn();
|
||||
findNodeExecutable({ logger });
|
||||
|
||||
// Logger should be called at least once (either found or fallback message)
|
||||
expect(logger).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a valid NodeFinderResult structure', () => {
|
||||
const result = findNodeExecutable();
|
||||
|
||||
expect(result).toHaveProperty('nodePath');
|
||||
expect(result).toHaveProperty('source');
|
||||
expect(typeof result.nodePath).toBe('string');
|
||||
expect(result.nodePath.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find node on the current system', () => {
|
||||
// This test verifies that node can be found on the test machine
|
||||
const result = findNodeExecutable();
|
||||
|
||||
// Should find node since we're running in Node.js
|
||||
expect(result.nodePath).toBeDefined();
|
||||
|
||||
// Source should be one of the valid sources
|
||||
const validSources = [
|
||||
'homebrew',
|
||||
'system',
|
||||
'nvm',
|
||||
'fnm',
|
||||
'nvm-windows',
|
||||
'program-files',
|
||||
'scoop',
|
||||
'chocolatey',
|
||||
'which',
|
||||
'where',
|
||||
'fallback',
|
||||
];
|
||||
expect(validSources).toContain(result.source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnhancedPath', () => {
|
||||
const delimiter = path.delimiter;
|
||||
|
||||
it("should return current path unchanged when nodePath is 'node'", () => {
|
||||
const currentPath = '/usr/bin:/usr/local/bin';
|
||||
const result = buildEnhancedPath('node', currentPath);
|
||||
|
||||
expect(result).toBe(currentPath);
|
||||
});
|
||||
|
||||
it("should return empty string when nodePath is 'node' and currentPath is empty", () => {
|
||||
const result = buildEnhancedPath('node', '');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should prepend node directory to path', () => {
|
||||
const nodePath = '/opt/homebrew/bin/node';
|
||||
const currentPath = '/usr/bin:/usr/local/bin';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, currentPath);
|
||||
|
||||
expect(result).toBe(`/opt/homebrew/bin${delimiter}${currentPath}`);
|
||||
});
|
||||
|
||||
it('should not duplicate node directory if already in path', () => {
|
||||
const nodePath = '/usr/local/bin/node';
|
||||
const currentPath = '/usr/local/bin:/usr/bin';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, currentPath);
|
||||
|
||||
expect(result).toBe(currentPath);
|
||||
});
|
||||
|
||||
it('should handle empty currentPath', () => {
|
||||
const nodePath = '/opt/homebrew/bin/node';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, '');
|
||||
|
||||
expect(result).toBe(`/opt/homebrew/bin${delimiter}`);
|
||||
});
|
||||
|
||||
it('should handle Windows-style paths', () => {
|
||||
const nodePath = 'C:\\Program Files\\nodejs\\node.exe';
|
||||
const currentPath = 'C:\\Windows\\System32';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, currentPath);
|
||||
|
||||
expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`);
|
||||
});
|
||||
|
||||
it('should use default empty string for currentPath', () => {
|
||||
const nodePath = '/usr/local/bin/node';
|
||||
|
||||
const result = buildEnhancedPath(nodePath);
|
||||
|
||||
expect(result).toBe(`/usr/local/bin${delimiter}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user