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 ``` diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts deleted file mode 100644 index 20e7bcfb..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. diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index d6d3d2a3..5c3fb52f 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -10,6 +10,7 @@ import { spawn, ChildProcess } from 'child_process'; import fs from 'fs'; import http, { Server } from 'http'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; +import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; // Development environment const isDev = !app.isPackaged; @@ -274,12 +275,22 @@ 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: string) => console.log(`[Electron] ${msg}`), + }); + const command = nodeResult.nodePath; + + // Validate that the found Node executable actually exists + if (command !== 'node' && !fs.existsSync(command)) { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } + let args: string[]; let serverPath: string; if (isDev) { - command = 'node'; serverPath = path.join(__dirname, '../../server/src/index.ts'); const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx'); @@ -302,7 +313,6 @@ async function startServer(): Promise { args = [tsxCliPath, 'watch', serverPath]; } else { - command = 'node'; serverPath = path.join(process.resourcesPath, 'server', 'index.js'); args = [serverPath]; @@ -315,8 +325,15 @@ async function startServer(): Promise { ? 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'), NODE_PATH: serverNodeModules, @@ -511,6 +528,16 @@ 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${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/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..ed2cbb03 --- /dev/null +++ b/libs/platform/src/node-finder.ts @@ -0,0 +1,386 @@ +/** + * Cross-platform Node.js executable finder + * + * Handles finding Node.js when the app is launched from desktop environments + * (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited. + */ + +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */ +const VERSION_DIR_PATTERN = /^v?\d+/; + +/** Pattern to identify pre-release versions (beta, rc, alpha, nightly, canary) */ +const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; + +/** Result of finding Node.js executable */ +export interface NodeFinderResult { + /** Path to the Node.js executable */ + nodePath: string; + /** How Node.js was found */ + source: + | 'homebrew' + | 'system' + | 'nvm' + | 'fnm' + | 'nvm-windows' + | 'program-files' + | 'scoop' + | 'chocolatey' + | 'which' + | 'where' + | 'fallback'; +} + +/** Options for finding Node.js */ +export interface NodeFinderOptions { + /** Skip the search and return 'node' immediately (useful for dev mode) */ + skipSearch?: boolean; + /** Custom logger function */ + logger?: (message: string) => void; +} + +/** + * Check if a file exists and is executable + * On Windows, only checks existence (X_OK is not meaningful) + */ +function isExecutable(filePath: string): boolean { + try { + if (process.platform === 'win32') { + // On Windows, fs.constants.X_OK is not meaningful - just check existence + fs.accessSync(filePath, fs.constants.F_OK); + } else { + // On Unix-like systems, check for execute permission + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +/** + * Find Node.js executable from version manager directories (NVM, fnm) + * Uses semantic version sorting to prefer the latest stable version + * Pre-release versions (beta, rc, alpha) are deprioritized but used as fallback + */ +function findNodeFromVersionManager( + basePath: string, + binSubpath: string = 'bin/node' +): string | null { + if (!fs.existsSync(basePath)) return null; + + try { + const allVersions = fs + .readdirSync(basePath) + .filter((v) => VERSION_DIR_PATTERN.test(v)) + // Semantic version sort - newest first using localeCompare with numeric option + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); + + // Separate stable and pre-release versions, preferring stable + const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); + const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); + + // Try stable versions first, then fall back to pre-release + for (const version of [...stableVersions, ...preReleaseVersions]) { + const nodePath = path.join(basePath, version, binSubpath); + if (isExecutable(nodePath)) { + return nodePath; + } + } + } catch { + // Directory read failed, skip this location + } + + return null; +} + +/** + * Find Node.js on macOS + */ +function findNodeMacOS(homeDir: string): NodeFinderResult | null { + // Check Homebrew paths in order of preference + const homebrewPaths = [ + // Apple Silicon + '/opt/homebrew/bin/node', + // Intel + '/usr/local/bin/node', + ]; + + for (const nodePath of homebrewPaths) { + if (isExecutable(nodePath)) { + return { nodePath, source: 'homebrew' }; + } + } + + // System Node + if (isExecutable('/usr/bin/node')) { + return { nodePath: '/usr/bin/node', source: 'system' }; + } + + // NVM installation + const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } + + // fnm installation (multiple possible locations) + const fnmPaths = [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'), + ]; + + for (const fnmBasePath of fnmPaths) { + const fnmNode = findNodeFromVersionManager(fnmBasePath); + if (fnmNode) { + return { nodePath: fnmNode, source: 'fnm' }; + } + } + + return null; +} + +/** + * Find Node.js on Linux + */ +function findNodeLinux(homeDir: string): NodeFinderResult | null { + // Common Linux paths + const systemPaths = [ + '/usr/bin/node', + '/usr/local/bin/node', + // Snap installation + '/snap/bin/node', + ]; + + for (const nodePath of systemPaths) { + if (isExecutable(nodePath)) { + return { nodePath, source: 'system' }; + } + } + + // NVM installation + const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } + + // fnm installation + const fnmPaths = [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, '.fnm', 'node-versions'), + ]; + + for (const fnmBasePath of fnmPaths) { + const fnmNode = findNodeFromVersionManager(fnmBasePath); + if (fnmNode) { + return { nodePath: fnmNode, source: 'fnm' }; + } + } + + return null; +} + +/** + * Find Node.js on Windows + */ +function findNodeWindows(homeDir: string): NodeFinderResult | null { + // Program Files paths + const programFilesPaths = [ + path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'), + path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'), + ]; + + for (const nodePath of programFilesPaths) { + if (isExecutable(nodePath)) { + return { nodePath, source: 'program-files' }; + } + } + + // NVM for Windows + const nvmWindowsPath = path.join( + process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), + 'nvm' + ); + const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe'); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm-windows' }; + } + + // fnm on Windows (prioritize canonical installation path over shell shims) + const fnmWindowsPaths = [ + path.join(homeDir, '.fnm', 'node-versions'), + path.join( + process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), + 'fnm', + 'node-versions' + ), + ]; + + for (const fnmBasePath of fnmWindowsPaths) { + const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe'); + if (fnmNode) { + return { nodePath: fnmNode, source: 'fnm' }; + } + } + + // Scoop installation + const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); + if (isExecutable(scoopPath)) { + return { nodePath: scoopPath, source: 'scoop' }; + } + + // Chocolatey installation + const chocoPath = path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'bin', + 'node.exe' + ); + if (isExecutable(chocoPath)) { + return { nodePath: chocoPath, source: 'chocolatey' }; + } + + return null; +} + +/** + * Try to find Node.js using shell commands (which/where) + */ +function findNodeViaShell( + platform: NodeJS.Platform, + logger: (message: string) => void = () => {} +): NodeFinderResult | null { + try { + const command = platform === 'win32' ? 'where node' : 'which node'; + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + // 'where' on Windows can return multiple lines, take the first + const nodePath = result.split(/\r?\n/)[0]; + + // Validate path: check for null bytes (security) and executable permission + if (nodePath && !nodePath.includes('\x00') && isExecutable(nodePath)) { + return { + nodePath, + source: platform === 'win32' ? 'where' : 'which', + }; + } + } catch { + // Shell command failed (likely when launched from desktop without PATH) + logger('Shell command failed to find Node.js (expected when launched from desktop)'); + } + + return null; +} + +/** + * Find Node.js executable - handles desktop launcher scenarios where PATH is limited + * + * @param options - Configuration options + * @returns Result with path and source information + * + * @example + * ```typescript + * import { findNodeExecutable } from '@automaker/platform'; + * + * // In development, skip the search + * const result = findNodeExecutable({ skipSearch: isDev }); + * console.log(`Using Node.js from ${result.source}: ${result.nodePath}`); + * + * // Spawn a process with the found Node.js + * spawn(result.nodePath, ['script.js']); + * ``` + */ +export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderResult { + const { skipSearch = false, logger = () => {} } = options; + + // Skip search if requested (e.g., in development mode) + if (skipSearch) { + return { nodePath: 'node', source: 'fallback' }; + } + + const platform = process.platform; + const homeDir = os.homedir(); + + // Platform-specific search + let result: NodeFinderResult | null = null; + + switch (platform) { + case 'darwin': + result = findNodeMacOS(homeDir); + break; + case 'linux': + result = findNodeLinux(homeDir); + break; + case 'win32': + result = findNodeWindows(homeDir); + break; + } + + if (result) { + logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); + return result; + } + + // Fallback - try shell resolution (works when launched from terminal) + result = findNodeViaShell(platform, logger); + if (result) { + logger(`Found Node.js via ${result.source} at: ${result.nodePath}`); + return result; + } + + // Ultimate fallback + logger('Could not find Node.js, falling back to "node"'); + return { nodePath: 'node', source: 'fallback' }; +} + +/** + * Build an enhanced PATH that includes the Node.js directory + * Useful for ensuring child processes can find Node.js + * + * @param nodePath - Path to the Node.js executable + * @param currentPath - Current PATH environment variable + * @returns Enhanced PATH with Node.js directory prepended if not already present + * + * @example + * ```typescript + * import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; + * + * const { nodePath } = findNodeExecutable(); + * const enhancedPath = buildEnhancedPath(nodePath, process.env.PATH); + * + * spawn(nodePath, ['script.js'], { + * env: { ...process.env, PATH: enhancedPath } + * }); + * ``` + */ +export function buildEnhancedPath(nodePath: string, currentPath: string = ''): string { + // If using fallback 'node', don't modify PATH + if (nodePath === 'node') { + return currentPath; + } + + const nodeDir = path.dirname(nodePath); + + // Don't add if already present or if it's just '.' + // Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18) + // Normalize paths for comparison to handle mixed separators on Windows + const normalizedNodeDir = path.normalize(nodeDir); + const pathSegments = currentPath.split(path.delimiter).map((s) => path.normalize(s)); + if (normalizedNodeDir === '.' || pathSegments.includes(normalizedNodeDir)) { + return currentPath; + } + + // Use platform-appropriate path separator + // Handle empty currentPath without adding trailing delimiter + if (!currentPath) { + return nodeDir; + } + return `${nodeDir}${path.delimiter}${currentPath}`; +} diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts new file mode 100644 index 00000000..6956884b --- /dev/null +++ b/libs/platform/tests/node-finder.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js'; +import path from 'path'; +import fs from 'fs'; + +describe('node-finder', () => { + describe('version sorting and pre-release filtering', () => { + // Test the PRE_RELEASE_PATTERN logic indirectly + const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i; + + it('should identify pre-release versions correctly', () => { + const preReleaseVersions = [ + 'v20.0.0-beta', + 'v18.17.0-rc1', + 'v19.0.0-alpha', + 'v21.0.0-nightly', + 'v20.0.0-canary', + 'v18.0.0-dev', + 'v17.0.0-pre', + ]; + + for (const version of preReleaseVersions) { + expect(PRE_RELEASE_PATTERN.test(version)).toBe(true); + } + }); + + it('should not match stable versions as pre-release', () => { + const stableVersions = ['v18.17.0', 'v20.10.0', 'v16.20.2', '18.17.0', 'v21.0.0']; + + for (const version of stableVersions) { + expect(PRE_RELEASE_PATTERN.test(version)).toBe(false); + } + }); + + it('should sort versions with numeric comparison', () => { + const versions = ['v18.9.0', 'v18.17.0', 'v20.0.0', 'v8.0.0']; + const sorted = [...versions].sort((a, b) => + b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }) + ); + + expect(sorted).toEqual(['v20.0.0', 'v18.17.0', 'v18.9.0', 'v8.0.0']); + }); + + it('should prefer stable over pre-release when filtering', () => { + const allVersions = ['v20.0.0-beta', 'v19.9.9', 'v18.17.0', 'v21.0.0-rc1']; + + const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v)); + const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v)); + const prioritized = [...stableVersions, ...preReleaseVersions]; + + // Stable versions should come first + expect(prioritized[0]).toBe('v19.9.9'); + expect(prioritized[1]).toBe('v18.17.0'); + // Pre-release versions should come after + expect(prioritized[2]).toBe('v20.0.0-beta'); + expect(prioritized[3]).toBe('v21.0.0-rc1'); + }); + }); + + describe('findNodeExecutable', () => { + it("should return 'node' with fallback source when skipSearch is true", () => { + const result = findNodeExecutable({ skipSearch: true }); + + expect(result.nodePath).toBe('node'); + expect(result.source).toBe('fallback'); + }); + + it('should call logger when node is found', () => { + const logger = vi.fn(); + findNodeExecutable({ logger }); + + // Logger should be called at least once (either found or fallback message) + expect(logger).toHaveBeenCalled(); + }); + + it('should return a valid NodeFinderResult structure', () => { + const result = findNodeExecutable(); + + expect(result).toHaveProperty('nodePath'); + expect(result).toHaveProperty('source'); + expect(typeof result.nodePath).toBe('string'); + expect(result.nodePath.length).toBeGreaterThan(0); + }); + + it('should find node on the current system', () => { + // This test verifies that node can be found on the test machine + const result = findNodeExecutable(); + + // Should find node since we're running in Node.js + expect(result.nodePath).toBeDefined(); + + // Source should be one of the valid sources + const validSources = [ + 'homebrew', + 'system', + 'nvm', + 'fnm', + 'nvm-windows', + 'program-files', + 'scoop', + 'chocolatey', + 'which', + 'where', + 'fallback', + ]; + expect(validSources).toContain(result.source); + }); + + it('should find an executable node binary', () => { + const result = findNodeExecutable(); + + // Skip this test if fallback is used (node not found via path search) + if (result.source === 'fallback') { + expect(result.nodePath).toBe('node'); + return; + } + + // Verify the found path is actually executable + if (process.platform === 'win32') { + // On Windows, just check file exists (X_OK is not meaningful) + expect(() => fs.accessSync(result.nodePath, fs.constants.F_OK)).not.toThrow(); + } else { + // On Unix-like systems, verify execute permission + expect(() => fs.accessSync(result.nodePath, fs.constants.X_OK)).not.toThrow(); + } + }); + }); + + describe('buildEnhancedPath', () => { + const delimiter = path.delimiter; + + it("should return current path unchanged when nodePath is 'node'", () => { + const currentPath = '/usr/bin:/usr/local/bin'; + const result = buildEnhancedPath('node', currentPath); + + expect(result).toBe(currentPath); + }); + + it("should return empty string when nodePath is 'node' and currentPath is empty", () => { + const result = buildEnhancedPath('node', ''); + + expect(result).toBe(''); + }); + + it('should prepend node directory to path', () => { + const nodePath = '/opt/homebrew/bin/node'; + const currentPath = '/usr/bin:/usr/local/bin'; + + const result = buildEnhancedPath(nodePath, currentPath); + + expect(result).toBe(`/opt/homebrew/bin${delimiter}${currentPath}`); + }); + + it('should not duplicate node directory if already in path', () => { + const nodePath = '/usr/local/bin/node'; + const currentPath = '/usr/local/bin:/usr/bin'; + + const result = buildEnhancedPath(nodePath, currentPath); + + expect(result).toBe(currentPath); + }); + + it('should handle empty currentPath without trailing delimiter', () => { + const nodePath = '/opt/homebrew/bin/node'; + + const result = buildEnhancedPath(nodePath, ''); + + expect(result).toBe('/opt/homebrew/bin'); + }); + + it('should handle Windows-style paths', () => { + // On Windows, path.dirname recognizes backslash paths + // On other platforms, backslash is not a path separator + const nodePath = 'C:\\Program Files\\nodejs\\node.exe'; + const currentPath = 'C:\\Windows\\System32'; + + const result = buildEnhancedPath(nodePath, currentPath); + + if (process.platform === 'win32') { + // On Windows, should prepend the node directory + expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`); + } else { + // On non-Windows, backslash paths are treated as relative paths + // path.dirname returns '.' so the function returns currentPath unchanged + expect(result).toBe(currentPath); + } + }); + + it('should use default empty string for currentPath', () => { + const nodePath = '/usr/local/bin/node'; + + const result = buildEnhancedPath(nodePath); + + expect(result).toBe('/usr/local/bin'); + }); + }); +});