mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge pull request #208 from AutoMaker-Org/fix/electron-node-path-finder-launch-v2
fix: add cross-platform Node.js executable finder for desktop launches
This commit is contained in:
@@ -118,7 +118,10 @@ cd automaker
|
|||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. Run Automaker (pick your mode)
|
# 3. Build local shared packages
|
||||||
|
npm run build:packages
|
||||||
|
|
||||||
|
# 4. Run Automaker (pick your mode)
|
||||||
npm run dev
|
npm run dev
|
||||||
# Then choose your run mode when prompted, or use specific commands below
|
# Then choose your run mode when prompted, or use specific commands below
|
||||||
```
|
```
|
||||||
|
|||||||
6
apps/app/next-env.d.ts
vendored
6
apps/app/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import './.next/dev/types/routes.d.ts';
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -10,6 +10,7 @@ import { spawn, ChildProcess } from 'child_process';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import http, { Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||||
|
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||||
|
|
||||||
// Development environment
|
// Development environment
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
@@ -274,12 +275,22 @@ async function startStaticServer(): Promise<void> {
|
|||||||
* Start the backend server
|
* Start the backend server
|
||||||
*/
|
*/
|
||||||
async function startServer(): Promise<void> {
|
async function startServer(): Promise<void> {
|
||||||
let command: string;
|
// Find Node.js executable (handles desktop launcher scenarios)
|
||||||
|
const nodeResult = findNodeExecutable({
|
||||||
|
skipSearch: isDev,
|
||||||
|
logger: (msg: string) => console.log(`[Electron] ${msg}`),
|
||||||
|
});
|
||||||
|
const command = nodeResult.nodePath;
|
||||||
|
|
||||||
|
// Validate that the found Node executable actually exists
|
||||||
|
if (command !== 'node' && !fs.existsSync(command)) {
|
||||||
|
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||||
|
}
|
||||||
|
|
||||||
let args: string[];
|
let args: string[];
|
||||||
let serverPath: string;
|
let serverPath: string;
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
command = 'node';
|
|
||||||
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
||||||
|
|
||||||
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
||||||
@@ -302,7 +313,6 @@ async function startServer(): Promise<void> {
|
|||||||
|
|
||||||
args = [tsxCliPath, 'watch', serverPath];
|
args = [tsxCliPath, 'watch', serverPath];
|
||||||
} else {
|
} else {
|
||||||
command = 'node';
|
|
||||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||||
args = [serverPath];
|
args = [serverPath];
|
||||||
|
|
||||||
@@ -315,8 +325,15 @@ async function startServer(): Promise<void> {
|
|||||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||||
: path.join(__dirname, '../../server/node_modules');
|
: path.join(__dirname, '../../server/node_modules');
|
||||||
|
|
||||||
|
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||||
|
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||||
|
if (enhancedPath !== process.env.PATH) {
|
||||||
|
console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
PATH: enhancedPath,
|
||||||
PORT: SERVER_PORT.toString(),
|
PORT: SERVER_PORT.toString(),
|
||||||
DATA_DIR: app.getPath('userData'),
|
DATA_DIR: app.getPath('userData'),
|
||||||
NODE_PATH: serverNodeModules,
|
NODE_PATH: serverNodeModules,
|
||||||
@@ -511,6 +528,16 @@ app.whenReady().then(async () => {
|
|||||||
createWindow();
|
createWindow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Electron] Failed to start:', error);
|
console.error('[Electron] Failed to start:', error);
|
||||||
|
const errorMessage = (error as Error).message;
|
||||||
|
const isNodeError = errorMessage.includes('Node.js');
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'Automaker Failed to Start',
|
||||||
|
`The application failed to start.\n\n${errorMessage}\n\n${
|
||||||
|
isNodeError
|
||||||
|
? 'Please install Node.js from https://nodejs.org or via a package manager (Homebrew, nvm, fnm).'
|
||||||
|
: 'Please check the application logs for more details.'
|
||||||
|
}`
|
||||||
|
);
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,3 +44,11 @@ export {
|
|||||||
|
|
||||||
// Secure file system (validates paths before I/O operations)
|
// Secure file system (validates paths before I/O operations)
|
||||||
export * as secureFs from './secure-fs.js';
|
export * as secureFs from './secure-fs.js';
|
||||||
|
|
||||||
|
// Node.js executable finder (cross-platform)
|
||||||
|
export {
|
||||||
|
findNodeExecutable,
|
||||||
|
buildEnhancedPath,
|
||||||
|
type NodeFinderResult,
|
||||||
|
type NodeFinderOptions,
|
||||||
|
} from './node-finder.js';
|
||||||
|
|||||||
386
libs/platform/src/node-finder.ts
Normal file
386
libs/platform/src/node-finder.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* On Windows, only checks existence (X_OK is not meaningful)
|
||||||
|
*/
|
||||||
|
function isExecutable(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// On Windows, fs.constants.X_OK is not meaningful - just check existence
|
||||||
|
fs.accessSync(filePath, fs.constants.F_OK);
|
||||||
|
} else {
|
||||||
|
// On Unix-like systems, check for execute permission
|
||||||
|
fs.accessSync(filePath, fs.constants.X_OK);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} 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 {
|
||||||
|
if (!fs.existsSync(basePath)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allVersions = fs
|
||||||
|
.readdirSync(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 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 (isExecutable(nodePath)) {
|
||||||
|
return { nodePath, source: 'homebrew' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System Node
|
||||||
|
if (isExecutable('/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 (isExecutable(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 (isExecutable(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 (isExecutable(scoopPath)) {
|
||||||
|
return { nodePath: scoopPath, source: 'scoop' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chocolatey installation
|
||||||
|
const chocoPath = path.join(
|
||||||
|
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
|
||||||
|
'bin',
|
||||||
|
'node.exe'
|
||||||
|
);
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
197
libs/platform/tests/node-finder.test.ts
Normal file
197
libs/platform/tests/node-finder.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
describe('node-finder', () => {
|
||||||
|
describe('version sorting and pre-release filtering', () => {
|
||||||
|
// Test the PRE_RELEASE_PATTERN logic indirectly
|
||||||
|
const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i;
|
||||||
|
|
||||||
|
it('should identify pre-release versions correctly', () => {
|
||||||
|
const preReleaseVersions = [
|
||||||
|
'v20.0.0-beta',
|
||||||
|
'v18.17.0-rc1',
|
||||||
|
'v19.0.0-alpha',
|
||||||
|
'v21.0.0-nightly',
|
||||||
|
'v20.0.0-canary',
|
||||||
|
'v18.0.0-dev',
|
||||||
|
'v17.0.0-pre',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const version of preReleaseVersions) {
|
||||||
|
expect(PRE_RELEASE_PATTERN.test(version)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match stable versions as pre-release', () => {
|
||||||
|
const stableVersions = ['v18.17.0', 'v20.10.0', 'v16.20.2', '18.17.0', 'v21.0.0'];
|
||||||
|
|
||||||
|
for (const version of stableVersions) {
|
||||||
|
expect(PRE_RELEASE_PATTERN.test(version)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort versions with numeric comparison', () => {
|
||||||
|
const versions = ['v18.9.0', 'v18.17.0', 'v20.0.0', 'v8.0.0'];
|
||||||
|
const sorted = [...versions].sort((a, b) =>
|
||||||
|
b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sorted).toEqual(['v20.0.0', 'v18.17.0', 'v18.9.0', 'v8.0.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer stable over pre-release when filtering', () => {
|
||||||
|
const allVersions = ['v20.0.0-beta', 'v19.9.9', 'v18.17.0', 'v21.0.0-rc1'];
|
||||||
|
|
||||||
|
const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v));
|
||||||
|
const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v));
|
||||||
|
const prioritized = [...stableVersions, ...preReleaseVersions];
|
||||||
|
|
||||||
|
// Stable versions should come first
|
||||||
|
expect(prioritized[0]).toBe('v19.9.9');
|
||||||
|
expect(prioritized[1]).toBe('v18.17.0');
|
||||||
|
// Pre-release versions should come after
|
||||||
|
expect(prioritized[2]).toBe('v20.0.0-beta');
|
||||||
|
expect(prioritized[3]).toBe('v21.0.0-rc1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find an executable node binary', () => {
|
||||||
|
const result = findNodeExecutable();
|
||||||
|
|
||||||
|
// Skip this test if fallback is used (node not found via path search)
|
||||||
|
if (result.source === 'fallback') {
|
||||||
|
expect(result.nodePath).toBe('node');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the found path is actually executable
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// On Windows, just check file exists (X_OK is not meaningful)
|
||||||
|
expect(() => fs.accessSync(result.nodePath, fs.constants.F_OK)).not.toThrow();
|
||||||
|
} else {
|
||||||
|
// On Unix-like systems, verify execute permission
|
||||||
|
expect(() => fs.accessSync(result.nodePath, fs.constants.X_OK)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 without trailing delimiter', () => {
|
||||||
|
const nodePath = '/opt/homebrew/bin/node';
|
||||||
|
|
||||||
|
const result = buildEnhancedPath(nodePath, '');
|
||||||
|
|
||||||
|
expect(result).toBe('/opt/homebrew/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Windows-style paths', () => {
|
||||||
|
// On Windows, path.dirname recognizes backslash paths
|
||||||
|
// On other platforms, backslash is not a path separator
|
||||||
|
const nodePath = 'C:\\Program Files\\nodejs\\node.exe';
|
||||||
|
const currentPath = 'C:\\Windows\\System32';
|
||||||
|
|
||||||
|
const result = buildEnhancedPath(nodePath, currentPath);
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// On Windows, should prepend the node directory
|
||||||
|
expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`);
|
||||||
|
} else {
|
||||||
|
// On non-Windows, backslash paths are treated as relative paths
|
||||||
|
// path.dirname returns '.' so the function returns currentPath unchanged
|
||||||
|
expect(result).toBe(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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user