From ebaecca94956044e28151fa96f24b2d44a81ebb7 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:11:58 +0100 Subject: [PATCH 1/9] 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}`); + }); + }); +}); From 49f04cf403986d1a5dd6cc3a98dfaf07379c3d62 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:12:32 +0100 Subject: [PATCH 2/9] chore: remove old file from apps/app --- apps/app/next-env.d.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/app/next-env.d.ts diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts deleted file mode 100644 index c4b7818f..00000000 --- a/apps/app/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -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. From d3005393dfc7dfffaf6f4aee7aa9c07d2564eacc Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:49:19 +0100 Subject: [PATCH 3/9] fix: address code review feedback for node-finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix PATH collision detection using proper path segment matching instead of substring includes() which could cause false positives - Reorder fnm Windows paths to prioritize canonical installation path over shell shims (fnm_multishells) - Make Windows path test platform-aware since path.dirname handles backslash paths differently on non-Windows systems 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 11 +++++++---- libs/platform/tests/node-finder.test.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 8dabfa3b..9b45d9e8 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -180,13 +180,14 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { return { nodePath: nvmNode, source: 'nvm-windows' }; } - // fnm on 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_multishells' + 'fnm', + 'node-versions' ), - path.join(homeDir, '.fnm', 'node-versions'), ]; for (const fnmBasePath of fnmWindowsPaths) { @@ -332,7 +333,9 @@ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): s const nodeDir = path.dirname(nodePath); // Don't add if already present or if it's just '.' - if (nodeDir === '.' || currentPath.includes(nodeDir)) { + // Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18) + const pathSegments = currentPath.split(path.delimiter); + if (nodeDir === '.' || pathSegments.includes(nodeDir)) { return currentPath; } diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 98521636..f37d0d85 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -96,12 +96,21 @@ describe('node-finder', () => { }); 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); - expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${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', () => { From 887fb93b3bb3ca28f28defd6be16eb377628d168 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 14:54:26 +0100 Subject: [PATCH 4/9] fix: address additional code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add path.normalize() for Windows mixed separator handling - Add validation to check Node executable exists after finding it - Improve error dialog with specific troubleshooting advice for Node.js related errors vs general errors - Include source info in validation error message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/main.ts | 14 +++++++++++++- libs/platform/src/node-finder.ts | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 2d1a0c2f..f4b181c1 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -134,6 +134,12 @@ async function startServer(): Promise { logger: (msg) => 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 serverPath: string; @@ -338,9 +344,15 @@ app.whenReady().then(async () => { createWindow(); } catch (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${(error as Error).message}\n\nPlease ensure Node.js is installed and accessible.` + `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(); } diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 9b45d9e8..cc130478 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -334,8 +334,10 @@ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): s // 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) - const pathSegments = currentPath.split(path.delimiter); - if (nodeDir === '.' || pathSegments.includes(nodeDir)) { + // 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; } From b18672f66def19c130d3f1540a23c350bee89fc2 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:01:32 +0100 Subject: [PATCH 5/9] refactor(platform): address code review feedback for node-finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract VERSION_DIR_PATTERN regex to named constant - Pass logger to findNodeViaShell for consistent debug logging - Fix buildEnhancedPath to not add trailing delimiter for empty currentPath 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 17 ++++++++++++++--- libs/platform/tests/node-finder.test.ts | 6 +++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index cc130478..9c29c308 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,6 +10,9 @@ 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") */ +const VERSION_DIR_PATTERN = /^v?\d+/; + /** Result of finding Node.js executable */ export interface NodeFinderResult { /** Path to the Node.js executable */ @@ -50,7 +53,7 @@ function findNodeFromVersionManager( try { const versions = fs .readdirSync(basePath) - .filter((v) => /^v?\d+/.test(v)) + .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' })); @@ -219,7 +222,10 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { /** * Try to find Node.js using shell commands (which/where) */ -function findNodeViaShell(platform: NodeJS.Platform): NodeFinderResult | null { +function findNodeViaShell( + platform: NodeJS.Platform, + logger: (message: string) => void = () => {} +): NodeFinderResult | null { try { const command = platform === 'win32' ? 'where node' : 'which node'; const result = execSync(command, { @@ -238,6 +244,7 @@ function findNodeViaShell(platform: NodeJS.Platform): NodeFinderResult | null { } } 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; @@ -293,7 +300,7 @@ export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderR } // Fallback - try shell resolution (works when launched from terminal) - result = findNodeViaShell(platform); + result = findNodeViaShell(platform, logger); if (result) { logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); return result; @@ -342,5 +349,9 @@ export function buildEnhancedPath(nodePath: string, currentPath: string = ''): s } // Use platform-appropriate path separator + // Handle empty currentPath without adding trailing delimiter + if (!currentPath) { + return nodeDir; + } return `${nodeDir}${path.delimiter}${currentPath}`; } diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index f37d0d85..4304fbbb 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -87,12 +87,12 @@ describe('node-finder', () => { expect(result).toBe(currentPath); }); - it('should handle empty 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${delimiter}`); + expect(result).toBe('/opt/homebrew/bin'); }); it('should handle Windows-style paths', () => { @@ -118,7 +118,7 @@ describe('node-finder', () => { const result = buildEnhancedPath(nodePath); - expect(result).toBe(`/usr/local/bin${delimiter}`); + expect(result).toBe('/usr/local/bin'); }); }); }); From b00568176c5751b822ea287638cc91b2be3d6514 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:07:38 +0100 Subject: [PATCH 6/9] refactor(platform): improve node-finder security and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add null byte validation to shell command output (security hardening) - Expand VERSION_DIR_PATTERN comment to explain intentional pre-release support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 9c29c308..399b38ee 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,7 +10,11 @@ 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") */ +/** + * Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") + * Intentionally permissive to match pre-release versions (v18.17.0-beta, v18.17.0-rc1) + * since localeCompare with numeric:true handles sorting correctly + */ const VERSION_DIR_PATTERN = /^v?\d+/; /** Result of finding Node.js executable */ @@ -236,7 +240,8 @@ function findNodeViaShell( // 'where' on Windows can return multiple lines, take the first const nodePath = result.split(/\r?\n/)[0]; - if (nodePath && fs.existsSync(nodePath)) { + // Validate path: check for null bytes (security) and existence + if (nodePath && !nodePath.includes('\x00') && fs.existsSync(nodePath)) { return { nodePath, source: platform === 'win32' ? 'where' : 'which', From 6e341c1c15e7a201aeb2b8911b46b9eb071cd811 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:16:33 +0100 Subject: [PATCH 7/9] feat(platform): add executable permission validation to node-finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isExecutable() helper to verify files have execute permission - On Unix: uses fs.constants.X_OK to check execute permission - On Windows: only checks file existence (X_OK not meaningful) - Replace fs.existsSync with isExecutable for all node path checks - Add JSDoc comment documenting version sorting limitations - Add test to verify found node binary is executable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 41 +++++++++++++++++++------ libs/platform/tests/node-finder.test.ts | 20 ++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index 399b38ee..cfe8d7d9 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -44,9 +44,32 @@ export interface NodeFinderOptions { 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 version + * + * Note: Version sorting uses localeCompare with numeric:true which handles most cases + * correctly (e.g., v18.17.0 > v18.9.0) but may not perfectly sort pre-release versions + * (e.g., v20.0.0-beta vs v19.9.9). This is acceptable as we prefer the latest stable. */ function findNodeFromVersionManager( basePath: string, @@ -63,7 +86,7 @@ function findNodeFromVersionManager( for (const version of versions) { const nodePath = path.join(basePath, version, binSubpath); - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return nodePath; } } @@ -87,13 +110,13 @@ function findNodeMacOS(homeDir: string): NodeFinderResult | null { ]; for (const nodePath of homebrewPaths) { - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return { nodePath, source: 'homebrew' }; } } // System Node - if (fs.existsSync('/usr/bin/node')) { + if (isExecutable('/usr/bin/node')) { return { nodePath: '/usr/bin/node', source: 'system' }; } @@ -133,7 +156,7 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null { ]; for (const nodePath of systemPaths) { - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return { nodePath, source: 'system' }; } } @@ -172,7 +195,7 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { ]; for (const nodePath of programFilesPaths) { - if (fs.existsSync(nodePath)) { + if (isExecutable(nodePath)) { return { nodePath, source: 'program-files' }; } } @@ -206,7 +229,7 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { // Scoop installation const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); - if (fs.existsSync(scoopPath)) { + if (isExecutable(scoopPath)) { return { nodePath: scoopPath, source: 'scoop' }; } @@ -216,7 +239,7 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { 'bin', 'node.exe' ); - if (fs.existsSync(chocoPath)) { + if (isExecutable(chocoPath)) { return { nodePath: chocoPath, source: 'chocolatey' }; } @@ -240,8 +263,8 @@ function findNodeViaShell( // '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 existence - if (nodePath && !nodePath.includes('\x00') && fs.existsSync(nodePath)) { + // Validate path: check for null bytes (security) and executable permission + if (nodePath && !nodePath.includes('\x00') && isExecutable(nodePath)) { return { nodePath, source: platform === 'win32' ? 'where' : 'which', diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 4304fbbb..62976446 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -1,6 +1,7 @@ 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('findNodeExecutable', () => { @@ -51,6 +52,25 @@ describe('node-finder', () => { ]; 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', () => { From 9f97426859ea10920da8981dc4fb7ded0a0aa83d Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:24:23 +0100 Subject: [PATCH 8/9] docs(README): update setup instructions to include package build step - Added a step to build local shared packages before running Automaker - Updated the sequence of instructions for clarity --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eb6e93d2..8d4347fe 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,10 @@ cd automaker # 2. Install dependencies 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 # Then choose your run mode when prompted, or use specific commands below ``` From 41ea6f78eb9f958f5ccd3fc72cd6f9535f3c0109 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 15:28:13 +0100 Subject: [PATCH 9/9] feat(platform): prefer stable Node.js versions over pre-releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PRE_RELEASE_PATTERN to identify beta, rc, alpha, nightly, canary, dev, pre versions - Modify findNodeFromVersionManager to try stable versions first - Pre-release versions are used as fallback if no stable version found - Add tests for pre-release detection and version prioritization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/node-finder.ts | 25 ++++++------ libs/platform/tests/node-finder.test.ts | 53 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index cfe8d7d9..ed2cbb03 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -10,13 +10,12 @@ 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") - * Intentionally permissive to match pre-release versions (v18.17.0-beta, v18.17.0-rc1) - * since localeCompare with numeric:true handles sorting correctly - */ +/** 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 */ @@ -65,11 +64,8 @@ function isExecutable(filePath: string): boolean { /** * Find Node.js executable from version manager directories (NVM, fnm) - * Uses semantic version sorting to prefer the latest version - * - * Note: Version sorting uses localeCompare with numeric:true which handles most cases - * correctly (e.g., v18.17.0 > v18.9.0) but may not perfectly sort pre-release versions - * (e.g., v20.0.0-beta vs v19.9.9). This is acceptable as we prefer the latest stable. + * 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, @@ -78,13 +74,18 @@ function findNodeFromVersionManager( if (!fs.existsSync(basePath)) return null; try { - const versions = fs + 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' })); - for (const version of versions) { + // 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; diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts index 62976446..6956884b 100644 --- a/libs/platform/tests/node-finder.test.ts +++ b/libs/platform/tests/node-finder.test.ts @@ -4,6 +4,59 @@ 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 });