diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index efc12ab7..2d1a0c2f 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -5,11 +5,12 @@ * Only native features (dialogs, shell) use IPC. */ -import path from "path"; -import { spawn, ChildProcess } from "child_process"; -import fs from "fs"; -import http, { Server } from "http"; -import { app, BrowserWindow, ipcMain, dialog, shell } from "electron"; +import path from 'path'; +import { spawn, ChildProcess } from 'child_process'; +import fs from 'fs'; +import http, { Server } from 'http'; +import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; +import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; // Development environment const isDev = !app.isPackaged; @@ -19,9 +20,9 @@ const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; if (isDev) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports - require("dotenv").config({ path: path.join(__dirname, "../.env") }); + require('dotenv').config({ path: path.join(__dirname, '../.env') }); } catch (error) { - console.warn("[Electron] dotenv not available:", (error as Error).message); + console.warn('[Electron] dotenv not available:', (error as Error).message); } } @@ -36,17 +37,17 @@ const STATIC_PORT = 3007; */ function getIconPath(): string | null { let iconFile: string; - if (process.platform === "win32") { - iconFile = "icon.ico"; - } else if (process.platform === "darwin") { - iconFile = "logo_larger.png"; + if (process.platform === 'win32') { + iconFile = 'icon.ico'; + } else if (process.platform === 'darwin') { + iconFile = 'logo_larger.png'; } else { - iconFile = "logo_larger.png"; + iconFile = 'logo_larger.png'; } const iconPath = isDev - ? path.join(__dirname, "../public", iconFile) - : path.join(__dirname, "../dist/public", iconFile); + ? path.join(__dirname, '../public', iconFile) + : path.join(__dirname, '../dist/public', iconFile); if (!fs.existsSync(iconPath)) { console.warn(`[Electron] Icon not found at: ${iconPath}`); @@ -60,18 +61,18 @@ function getIconPath(): string | null { * Start static file server for production builds */ async function startStaticServer(): Promise { - const staticPath = path.join(__dirname, "../dist"); + const staticPath = path.join(__dirname, '../dist'); staticServer = http.createServer((request, response) => { - let filePath = path.join(staticPath, request.url?.split("?")[0] || "/"); + let filePath = path.join(staticPath, request.url?.split('?')[0] || '/'); - if (filePath.endsWith("/")) { - filePath = path.join(filePath, "index.html"); + if (filePath.endsWith('/')) { + filePath = path.join(filePath, 'index.html'); } else if (!path.extname(filePath)) { // For client-side routing, serve index.html for paths without extensions - const possibleFile = filePath + ".html"; + const possibleFile = filePath + '.html'; if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) { - filePath = path.join(staticPath, "index.html"); + filePath = path.join(staticPath, 'index.html'); } else if (fs.existsSync(possibleFile)) { filePath = possibleFile; } @@ -79,35 +80,35 @@ async function startStaticServer(): Promise { fs.stat(filePath, (err, stats) => { if (err || !stats?.isFile()) { - filePath = path.join(staticPath, "index.html"); + filePath = path.join(staticPath, 'index.html'); } fs.readFile(filePath, (error, content) => { if (error) { response.writeHead(500); - response.end("Server Error"); + response.end('Server Error'); return; } const ext = path.extname(filePath); const contentTypes: Record = { - ".html": "text/html", - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".eot": "application/vnd.ms-fontobject", + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject', }; response.writeHead(200, { - "Content-Type": contentTypes[ext] || "application/octet-stream", + 'Content-Type': contentTypes[ext] || 'application/octet-stream', }); response.end(content); }); @@ -116,12 +117,10 @@ async function startStaticServer(): Promise { return new Promise((resolve, reject) => { staticServer!.listen(STATIC_PORT, () => { - console.log( - `[Electron] Static server running at http://localhost:${STATIC_PORT}` - ); + console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`); resolve(); }); - staticServer!.on("error", reject); + staticServer!.on('error', reject); }); } @@ -129,41 +128,39 @@ async function startStaticServer(): Promise { * Start the backend server */ async function startServer(): Promise { - let command: string; + // Find Node.js executable (handles desktop launcher scenarios) + const nodeResult = findNodeExecutable({ + skipSearch: isDev, + logger: (msg) => console.log(`[Electron] ${msg}`), + }); + const command = nodeResult.nodePath; let args: string[]; let serverPath: string; 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 rootNodeModules = path.join(__dirname, "../../../node_modules/tsx"); + const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx'); + const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx'); let tsxCliPath: string; - if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) { - tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs"); - } else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) { - tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs"); + if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); + } else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); } else { try { - tsxCliPath = require.resolve("tsx/cli.mjs", { - paths: [path.join(__dirname, "../../server")], + tsxCliPath = require.resolve('tsx/cli.mjs', { + paths: [path.join(__dirname, '../../server')], }); } catch { - throw new Error( - "Could not find tsx. Please run 'npm install' in the server directory." - ); + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); } } - args = [tsxCliPath, "watch", serverPath]; + args = [tsxCliPath, 'watch', serverPath]; } else { - command = "node"; - serverPath = path.join(process.resourcesPath, "server", "index.js"); + serverPath = path.join(process.resourcesPath, 'server', 'index.js'); args = [serverPath]; if (!fs.existsSync(serverPath)) { @@ -172,13 +169,20 @@ async function startServer(): Promise { } const serverNodeModules = app.isPackaged - ? path.join(process.resourcesPath, "server", "node_modules") - : path.join(__dirname, "../../server/node_modules"); + ? path.join(process.resourcesPath, '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 = { ...process.env, + PATH: enhancedPath, PORT: SERVER_PORT.toString(), - DATA_DIR: app.getPath("userData"), + DATA_DIR: app.getPath('userData'), NODE_PATH: serverNodeModules, // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment // If not set, server will allow access to all paths @@ -187,30 +191,30 @@ async function startServer(): Promise { }), }; - console.log("[Electron] Starting backend server..."); - console.log("[Electron] Server path:", serverPath); - console.log("[Electron] NODE_PATH:", serverNodeModules); + console.log('[Electron] Starting backend server...'); + console.log('[Electron] Server path:', serverPath); + console.log('[Electron] NODE_PATH:', serverNodeModules); serverProcess = spawn(command, args, { cwd: path.dirname(serverPath), env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ['ignore', 'pipe', 'pipe'], }); - serverProcess.stdout?.on("data", (data) => { + serverProcess.stdout?.on('data', (data) => { console.log(`[Server] ${data.toString().trim()}`); }); - serverProcess.stderr?.on("data", (data) => { + serverProcess.stderr?.on('data', (data) => { console.error(`[Server Error] ${data.toString().trim()}`); }); - serverProcess.on("close", (code) => { + serverProcess.on('close', (code) => { console.log(`[Server] Process exited with code ${code}`); serverProcess = null; }); - serverProcess.on("error", (err) => { + serverProcess.on('error', (err) => { console.error(`[Server] Failed to start server process:`, err); serverProcess = null; }); @@ -225,30 +229,27 @@ async function waitForServer(maxAttempts = 30): Promise { for (let i = 0; i < maxAttempts; i++) { try { await new Promise((resolve, reject) => { - const req = http.get( - `http://localhost:${SERVER_PORT}/api/health`, - (res) => { - if (res.statusCode === 200) { - resolve(); - } else { - reject(new Error(`Status: ${res.statusCode}`)); - } + const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + reject(new Error(`Status: ${res.statusCode}`)); } - ); - req.on("error", reject); + }); + req.on('error', reject); req.setTimeout(1000, () => { req.destroy(); - reject(new Error("Timeout")); + reject(new Error('Timeout')); }); }); - console.log("[Electron] Server is ready"); + console.log('[Electron] Server is ready'); return; } catch { await new Promise((r) => setTimeout(r, 500)); } } - throw new Error("Server failed to start"); + throw new Error('Server failed to start'); } /** @@ -262,12 +263,12 @@ function createWindow(): void { minWidth: 1280, minHeight: 768, webPreferences: { - preload: path.join(__dirname, "preload.js"), + preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, - titleBarStyle: "hiddenInset", - backgroundColor: "#0a0a0a", + titleBarStyle: 'hiddenInset', + backgroundColor: '#0a0a0a', }; if (iconPath) { @@ -286,17 +287,17 @@ function createWindow(): void { mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); } - if (isDev && process.env.OPEN_DEVTOOLS === "true") { + if (isDev && process.env.OPEN_DEVTOOLS === 'true') { mainWindow.webContents.openDevTools(); } - mainWindow.on("closed", () => { + mainWindow.on('closed', () => { mainWindow = null; }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); - return { action: "deny" }; + return { action: 'deny' }; }); } @@ -304,28 +305,22 @@ function createWindow(): void { app.whenReady().then(async () => { // Ensure userData path is consistent across dev/prod so files land in Automaker dir try { - const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker"); - if (app.getPath("userData") !== desiredUserDataPath) { - app.setPath("userData", desiredUserDataPath); - console.log("[Electron] userData path set to:", desiredUserDataPath); + const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker'); + if (app.getPath('userData') !== desiredUserDataPath) { + app.setPath('userData', desiredUserDataPath); + console.log('[Electron] userData path set to:', desiredUserDataPath); } } catch (error) { - console.warn( - "[Electron] Failed to set userData path:", - (error as Error).message - ); + console.warn('[Electron] Failed to set userData path:', (error as Error).message); } - if (process.platform === "darwin" && app.dock) { + if (process.platform === 'darwin' && app.dock) { const iconPath = getIconPath(); if (iconPath) { try { app.dock.setIcon(iconPath); } catch (error) { - console.warn( - "[Electron] Failed to set dock icon:", - (error as Error).message - ); + console.warn('[Electron] Failed to set dock icon:', (error as Error).message); } } } @@ -342,32 +337,36 @@ app.whenReady().then(async () => { // Create window createWindow(); } catch (error) { - console.error("[Electron] Failed to start:", error); + console.error('[Electron] Failed to start:', error); + dialog.showErrorBox( + 'Automaker Failed to Start', + `The application failed to start.\n\n${(error as Error).message}\n\nPlease ensure Node.js is installed and accessible.` + ); app.quit(); } - app.on("activate", () => { + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { app.quit(); } }); -app.on("before-quit", () => { +app.on('before-quit', () => { if (serverProcess) { - console.log("[Electron] Stopping server..."); + console.log('[Electron] Stopping server...'); serverProcess.kill(); serverProcess = null; } if (staticServer) { - console.log("[Electron] Stopping static server..."); + console.log('[Electron] Stopping static server...'); staticServer.close(); staticServer = null; } @@ -378,28 +377,28 @@ app.on("before-quit", () => { // ============================================ // Native file dialogs -ipcMain.handle("dialog:openDirectory", async () => { +ipcMain.handle('dialog:openDirectory', async () => { if (!mainWindow) { return { canceled: true, filePaths: [] }; } const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openDirectory", "createDirectory"], + properties: ['openDirectory', 'createDirectory'], }); return result; }); -ipcMain.handle("dialog:openFile", async (_, options = {}) => { +ipcMain.handle('dialog:openFile', async (_, options = {}) => { if (!mainWindow) { return { canceled: true, filePaths: [] }; } const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openFile"], + properties: ['openFile'], ...options, }); return result; }); -ipcMain.handle("dialog:saveFile", async (_, options = {}) => { +ipcMain.handle('dialog:saveFile', async (_, options = {}) => { if (!mainWindow) { return { canceled: true, filePath: undefined }; } @@ -408,7 +407,7 @@ ipcMain.handle("dialog:saveFile", async (_, options = {}) => { }); // Shell operations -ipcMain.handle("shell:openExternal", async (_, url: string) => { +ipcMain.handle('shell:openExternal', async (_, url: string) => { try { await shell.openExternal(url); return { success: true }; @@ -417,7 +416,7 @@ ipcMain.handle("shell:openExternal", async (_, url: string) => { } }); -ipcMain.handle("shell:openPath", async (_, filePath: string) => { +ipcMain.handle('shell:openPath', async (_, filePath: string) => { try { await shell.openPath(filePath); return { success: true }; @@ -427,27 +426,24 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => { }); // App info -ipcMain.handle( - "app:getPath", - async (_, name: Parameters[0]) => { - return app.getPath(name); - } -); +ipcMain.handle('app:getPath', async (_, name: Parameters[0]) => { + return app.getPath(name); +}); -ipcMain.handle("app:getVersion", async () => { +ipcMain.handle('app:getVersion', async () => { return app.getVersion(); }); -ipcMain.handle("app:isPackaged", async () => { +ipcMain.handle('app:isPackaged', async () => { return app.isPackaged; }); // Ping - for connection check -ipcMain.handle("ping", async () => { - return "pong"; +ipcMain.handle('ping', async () => { + return 'pong'; }); // Get server URL for HTTP client -ipcMain.handle("server:getUrl", async () => { +ipcMain.handle('server:getUrl', async () => { return `http://localhost:${SERVER_PORT}`; }); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 0794e109..ae4cc0d8 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -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'; diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts new file mode 100644 index 00000000..8dabfa3b --- /dev/null +++ b/libs/platform/src/node-finder.ts @@ -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}`; +} diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts new file mode 100644 index 00000000..98521636 --- /dev/null +++ b/libs/platform/tests/node-finder.test.ts @@ -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}`); + }); + }); +});