From ebaecca94956044e28151fa96f24b2d44a81ebb7 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:11:58 +0100 Subject: [PATCH] fix: add cross-platform Node.js executable finder for desktop launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ui/src/main.ts | 252 +++++++++-------- libs/platform/src/index.ts | 8 + libs/platform/src/node-finder.ts | 341 ++++++++++++++++++++++++ libs/platform/tests/node-finder.test.ts | 115 ++++++++ 4 files changed, 588 insertions(+), 128 deletions(-) create mode 100644 libs/platform/src/node-finder.ts create mode 100644 libs/platform/tests/node-finder.test.ts 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}`); + }); + }); +});