diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..812732d5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,34 @@ -npx lint-staged +#!/usr/bin/env sh + +# Try to load nvm if available (optional - works without it too) +if [ -z "$NVM_DIR" ]; then + # Check for Herd's nvm first (macOS with Herd) + if [ -s "$HOME/Library/Application Support/Herd/config/nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/Library/Application Support/Herd/config/nvm" + # Then check standard nvm location + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/.nvm" + fi +fi + +# Source nvm if found (silently skip if not available) +[ -n "$NVM_DIR" ] && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 2>/dev/null + +# Load node version from .nvmrc if using nvm (silently skip if nvm not available) +[ -f .nvmrc ] && command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 + +# Ensure common system paths are in PATH (for systems without nvm) +# This helps find node/npm installed via Homebrew, system packages, etc. +export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" + +# Run lint-staged - works with or without nvm +# Prefer npx, fallback to npm exec, both work with system-installed Node.js +if command -v npx >/dev/null 2>&1; then + npx lint-staged +elif command -v npm >/dev/null 2>&1; then + npm exec -- lint-staged +else + echo "Error: Neither npx nor npm found in PATH." + echo "Please ensure Node.js is installed (via nvm, Homebrew, system package manager, etc.)" + exit 1 +fi diff --git a/dev.mjs b/dev.mjs new file mode 100644 index 00000000..f2ad01cc --- /dev/null +++ b/dev.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +/** + * Automaker - Development Mode Launch Script + * + * This script starts the application in development mode with hot reloading. + * It uses Vite dev server for fast HMR during development. + * + * Usage: npm run dev + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +import { + createRestrictedFs, + log, + runNpm, + runNpmAndWait, + printHeader, + printModeMenu, + resolvePortConfiguration, + createCleanupHandler, + setupSignalHandlers, + startServerAndWait, + ensureDependencies, + prompt, +} from './scripts/launcher-utils.mjs'; + +const require = createRequire(import.meta.url); +const crossSpawn = require('cross-spawn'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Create restricted fs for this script's directory +const fs = createRestrictedFs(__dirname, 'dev.mjs'); + +// Track background processes for cleanup +const processes = { + server: null, + web: null, + electron: null, +}; + +/** + * Install Playwright browsers (dev-only dependency) + */ +async function installPlaywrightBrowsers() { + log('Checking Playwright browsers...', 'yellow'); + try { + const exitCode = await new Promise((resolve) => { + const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], { + stdio: 'inherit', + cwd: path.join(__dirname, 'apps', 'ui'), + }); + playwright.on('close', (code) => resolve(code)); + playwright.on('error', () => resolve(1)); + }); + + if (exitCode === 0) { + log('Playwright browsers ready', 'green'); + } else { + log('Playwright installation failed (browser automation may not work)', 'yellow'); + } + } catch { + log('Playwright installation skipped', 'yellow'); + } +} + +/** + * Main function + */ +async function main() { + // Change to script directory + process.chdir(__dirname); + + printHeader('Automaker Development Environment'); + + // Ensure dependencies are installed + await ensureDependencies(fs, __dirname); + + // Install Playwright browsers (dev-only) + await installPlaywrightBrowsers(); + + // Resolve port configuration (check/kill/change ports) + const { webPort, serverPort, corsOriginEnv } = await resolvePortConfiguration(); + + // Show mode selection menu + printModeMenu(); + + // Setup cleanup handlers + const cleanup = createCleanupHandler(processes); + setupSignalHandlers(cleanup); + + // Prompt for choice + while (true) { + const choice = await prompt('Enter your choice (1 or 2): '); + + if (choice === '1') { + console.log(''); + log('Launching Web Application (Development Mode)...', 'blue'); + + // Build shared packages once + log('Building shared packages...', 'blue'); + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname); + + // Start the backend server in dev mode + processes.server = await startServerAndWait({ + serverPort, + corsOriginEnv, + npmArgs: ['run', '_dev:server'], + cwd: __dirname, + fs, + baseDir: __dirname, + }); + + if (!processes.server) { + cleanup(); + process.exit(1); + } + + log(`The application will be available at: http://localhost:${webPort}`, 'green'); + console.log(''); + + // Start web app with Vite dev server (HMR enabled) + processes.web = runNpm( + ['run', '_dev:web'], + { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }, + __dirname + ); + + await new Promise((resolve) => { + processes.web.on('close', resolve); + }); + + break; + } else if (choice === '2') { + console.log(''); + log('Launching Desktop Application (Development Mode)...', 'blue'); + log('(Electron will start its own backend server)', 'yellow'); + console.log(''); + + // Pass selected ports through to Vite + Electron backend + processes.electron = runNpm( + ['run', 'dev:electron'], + { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + }, + }, + __dirname + ); + + await new Promise((resolve) => { + processes.electron.on('close', resolve); + }); + + break; + } else { + log('Invalid choice. Please enter 1 or 2.', 'red'); + } + } +} + +// Run main function +main().catch((err) => { + console.error(err); + const cleanup = createCleanupHandler(processes); + cleanup(); + process.exit(1); +}); diff --git a/init.mjs b/init.mjs deleted file mode 100644 index 49d47fa6..00000000 --- a/init.mjs +++ /dev/null @@ -1,649 +0,0 @@ -#!/usr/bin/env node - -/** - * Automaker - Cross-Platform Development Environment Setup and Launch Script - * - * This script works on Windows, macOS, and Linux. - * - * SECURITY NOTE: This script uses a restricted fs wrapper that only allows - * operations within the script's directory (__dirname). This is a standalone - * launch script that runs before the platform library is available. - */ - -import { execSync } from 'child_process'; -import fsNative from 'fs'; -import http from 'http'; -import path from 'path'; -import readline from 'readline'; -import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); -const treeKill = require('tree-kill'); -const crossSpawn = require('cross-spawn'); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// ============================================================================= -// Restricted fs wrapper - only allows operations within __dirname -// ============================================================================= - -/** - * Validate that a path is within the script's directory - * @param {string} targetPath - Path to validate - * @returns {string} - Resolved path if valid - * @throws {Error} - If path is outside __dirname - */ -function validateScriptPath(targetPath) { - const resolved = path.resolve(__dirname, targetPath); - const normalizedBase = path.resolve(__dirname); - if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { - throw new Error( - `[init.mjs] Security: Path access denied outside script directory: ${targetPath}` - ); - } - return resolved; -} - -/** - * Restricted fs operations - only within script directory - */ -const fs = { - existsSync(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.existsSync(validated); - }, - mkdirSync(targetPath, options) { - const validated = validateScriptPath(targetPath); - return fsNative.mkdirSync(validated, options); - }, - createWriteStream(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.createWriteStream(validated); - }, -}; - -// Colors for terminal output (works on modern terminals including Windows) -const colors = { - green: '\x1b[0;32m', - blue: '\x1b[0;34m', - yellow: '\x1b[1;33m', - red: '\x1b[0;31m', - reset: '\x1b[0m', -}; - -const isWindows = process.platform === 'win32'; - -// Track background processes for cleanup -let serverProcess = null; -let webProcess = null; -let electronProcess = null; - -/** - * Print colored output - */ -function log(message, color = 'reset') { - console.log(`${colors[color]}${message}${colors.reset}`); -} - -/** - * Print the header banner - */ -function printHeader() { - console.log('╔═══════════════════════════════════════════════════════╗'); - console.log('║ Automaker Development Environment ║'); - console.log('╚═══════════════════════════════════════════════════════╝'); - console.log(''); -} - -/** - * Execute a command synchronously and return stdout - */ -function execCommand(command, options = {}) { - try { - return execSync(command, { - encoding: 'utf8', - stdio: 'pipe', - ...options, - }).trim(); - } catch { - return null; - } -} - -/** - * Get process IDs using a specific port (cross-platform) - */ -function getProcessesOnPort(port) { - const pids = new Set(); - - if (isWindows) { - // Windows: Use netstat to find PIDs - try { - const output = execCommand(`netstat -ano | findstr :${port}`); - if (output) { - const lines = output.split('\n'); - for (const line of lines) { - // Match lines with LISTENING or ESTABLISHED on our port - const match = line.match(/:\d+\s+.*?(\d+)\s*$/); - if (match) { - const pid = parseInt(match[1], 10); - if (pid > 0) pids.add(pid); - } - } - } - } catch { - // Ignore errors - } - } else { - // Unix: Use lsof - try { - const output = execCommand(`lsof -ti:${port}`); - if (output) { - output.split('\n').forEach((pid) => { - const parsed = parseInt(pid.trim(), 10); - if (parsed > 0) pids.add(parsed); - }); - } - } catch { - // Ignore errors - } - } - - return Array.from(pids); -} - -/** - * Kill a process by PID (cross-platform) - */ -function killProcess(pid) { - try { - if (isWindows) { - execCommand(`taskkill /F /PID ${pid}`); - } else { - process.kill(pid, 'SIGKILL'); - } - return true; - } catch { - return false; - } -} - -/** - * Check if a port is in use (without killing) - */ -function isPortInUse(port) { - const pids = getProcessesOnPort(port); - return pids.length > 0; -} - -/** - * Kill processes on a port and wait for it to be freed - */ -async function killPort(port) { - const pids = getProcessesOnPort(port); - - if (pids.length === 0) { - log(`✓ Port ${port} is available`, 'green'); - return true; - } - - log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); - - for (const pid of pids) { - killProcess(pid); - } - - // Wait for port to be freed (max 5 seconds) - for (let i = 0; i < 10; i++) { - await sleep(500); - const remainingPids = getProcessesOnPort(port); - if (remainingPids.length === 0) { - log(`✓ Port ${port} is now free`, 'green'); - return true; - } - } - - log(`Warning: Port ${port} may still be in use`, 'red'); - return false; -} - -/** - * Sleep for a given number of milliseconds - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Check if the server health endpoint is responding - */ -function checkHealth(port = 3008) { - return new Promise((resolve) => { - const req = http.get(`http://localhost:${port}/api/health`, (res) => { - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.setTimeout(2000, () => { - req.destroy(); - resolve(false); - }); - }); -} - -/** - * Prompt the user for input - */ -function prompt(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -/** - * Run npm command using cross-spawn for Windows compatibility - */ -function runNpm(args, options = {}) { - const { env, ...restOptions } = options; - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...restOptions, - // Ensure environment variables are properly merged with process.env - env: { - ...process.env, - ...(env || {}), - }, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npm', args, spawnOptions); -} - -/** - * Run an npm command and wait for completion - */ -function runNpmAndWait(args, options = {}) { - const child = runNpm(args, options); - return new Promise((resolve, reject) => { - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); - }); - child.on('error', (err) => reject(err)); - }); -} - -/** - * Run npx command using cross-spawn for Windows compatibility - */ -function runNpx(args, options = {}) { - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...options, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npx', args, spawnOptions); -} - -/** - * Kill a process tree using tree-kill - */ -function killProcessTree(pid) { - return new Promise((resolve) => { - if (!pid) { - resolve(); - return; - } - treeKill(pid, 'SIGTERM', (err) => { - if (err) { - // Try force kill if graceful termination fails - treeKill(pid, 'SIGKILL', () => resolve()); - } else { - resolve(); - } - }); - }); -} - -/** - * Cleanup function to kill all spawned processes - */ -async function cleanup() { - console.log('\nCleaning up...'); - - const killPromises = []; - - if (serverProcess && !serverProcess.killed && serverProcess.pid) { - killPromises.push(killProcessTree(serverProcess.pid)); - } - - if (webProcess && !webProcess.killed && webProcess.pid) { - killPromises.push(killProcessTree(webProcess.pid)); - } - - if (electronProcess && !electronProcess.killed && electronProcess.pid) { - killPromises.push(killProcessTree(electronProcess.pid)); - } - - await Promise.all(killPromises); -} - -/** - * Main function - */ -async function main() { - // Change to script directory - process.chdir(__dirname); - - printHeader(); - - // Check if node_modules exists - if (!fs.existsSync(path.join(__dirname, 'node_modules'))) { - log('Installing dependencies...', 'blue'); - const install = runNpm(['install'], { stdio: 'inherit' }); - await new Promise((resolve, reject) => { - install.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm install failed with code ${code}`)); - }); - }); - } - - // Install Playwright browsers from apps/ui where @playwright/test is installed - log('Checking Playwright browsers...', 'yellow'); - try { - const exitCode = await new Promise((resolve) => { - const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - }); - playwright.on('close', (code) => resolve(code)); - playwright.on('error', () => resolve(1)); - }); - - if (exitCode === 0) { - log('Playwright browsers ready', 'green'); - } else { - log('Playwright installation failed (browser automation may not work)', 'yellow'); - } - } catch { - log('Playwright installation skipped', 'yellow'); - } - - // Check for processes on required ports and prompt user - log('Checking for processes on ports 3007 and 3008...', 'yellow'); - - const webPortInUse = isPortInUse(3007); - const serverPortInUse = isPortInUse(3008); - - let webPort = 3007; - let serverPort = 3008; - let corsOriginEnv = process.env.CORS_ORIGIN || ''; - - if (webPortInUse || serverPortInUse) { - console.log(''); - if (webPortInUse) { - const pids = getProcessesOnPort(3007); - log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - if (serverPortInUse) { - const pids = getProcessesOnPort(3008); - log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - console.log(''); - - while (true) { - const choice = await prompt( - 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' - ); - const lowerChoice = choice.toLowerCase(); - - if (lowerChoice === 'k' || lowerChoice === 'kill') { - if (webPortInUse) { - await killPort(3007); - } else { - log(`✓ Port 3007 is available`, 'green'); - } - if (serverPortInUse) { - await killPort(3008); - } else { - log(`✓ Port 3008 is available`, 'green'); - } - break; - } else if (lowerChoice === 'u' || lowerChoice === 'use') { - // Prompt for new ports - while (true) { - const newWebPort = await prompt('Enter web port (default 3007): '); - const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; - - if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (isPortInUse(parsedWebPort)) { - const pids = getProcessesOnPort(parsedWebPort); - log( - `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - webPort = parsedWebPort; - break; - } - - while (true) { - const newServerPort = await prompt('Enter server port (default 3008): '); - const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; - - if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (parsedServerPort === webPort) { - log('Server port cannot be the same as web port.', 'red'); - continue; - } - - if (isPortInUse(parsedServerPort)) { - const pids = getProcessesOnPort(parsedServerPort); - log( - `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - serverPort = parsedServerPort; - break; - } - - log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); - break; - } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { - log('Cancelled.', 'yellow'); - process.exit(0); - } else { - log( - 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', - 'red' - ); - } - } - } else { - log(`✓ Port 3007 is available`, 'green'); - log(`✓ Port 3008 is available`, 'green'); - } - - // Ensure backend CORS allows whichever UI port we ended up using. - // If CORS_ORIGIN is set, server enforces it strictly (see apps/server/src/index.ts), - // so we must include the selected web origin(s) in that list. - { - const existing = (process.env.CORS_ORIGIN || '') - .split(',') - .map((o) => o.trim()) - .filter(Boolean) - .filter((o) => o !== '*'); - const origins = new Set(existing); - origins.add(`http://localhost:${webPort}`); - origins.add(`http://127.0.0.1:${webPort}`); - corsOriginEnv = Array.from(origins).join(','); - } - console.log(''); - - // Show menu - console.log('═══════════════════════════════════════════════════════'); - console.log(' Select Application Mode:'); - console.log('═══════════════════════════════════════════════════════'); - console.log(' 1) Web Application (Browser)'); - console.log(' 2) Desktop Application (Electron)'); - console.log('═══════════════════════════════════════════════════════'); - console.log(''); - - // Setup cleanup handlers - let cleaningUp = false; - const handleExit = async (signal) => { - if (cleaningUp) return; - cleaningUp = true; - await cleanup(); - process.exit(0); - }; - - process.on('SIGINT', () => handleExit('SIGINT')); - process.on('SIGTERM', () => handleExit('SIGTERM')); - - // Prompt for choice - while (true) { - const choice = await prompt('Enter your choice (1 or 2): '); - - if (choice === '1') { - console.log(''); - log('Launching Web Application...', 'blue'); - - // Build shared packages once (dev:server and dev:web both do this at the root level) - log('Building shared packages...', 'blue'); - await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); - - // Start the backend server - log(`Starting backend server on port ${serverPort}...`, 'blue'); - - // Create logs directory - if (!fs.existsSync(path.join(__dirname, 'logs'))) { - fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); - } - - // Start server in background, showing output in console AND logging to file - const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); - serverProcess = runNpm(['run', '_dev:server'], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { - PORT: String(serverPort), - CORS_ORIGIN: corsOriginEnv, - }, - }); - - // Pipe to both log file and console so user can see API key - serverProcess.stdout?.on('data', (data) => { - process.stdout.write(data); - logStream.write(data); - }); - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(data); - logStream.write(data); - }); - - log('Waiting for server to be ready...', 'yellow'); - - // Wait for server health check - const maxRetries = 30; - let serverReady = false; - - for (let i = 0; i < maxRetries; i++) { - if (await checkHealth(serverPort)) { - serverReady = true; - break; - } - process.stdout.write('.'); - await sleep(1000); - } - - console.log(''); - - if (!serverReady) { - log('Error: Server failed to start', 'red'); - console.log('Check logs/server.log for details'); - cleanup(); - process.exit(1); - } - - log('✓ Server is ready!', 'green'); - log(`The application will be available at: http://localhost:${webPort}`, 'green'); - console.log(''); - - // Start web app - webProcess = runNpm(['run', '_dev:web'], { - stdio: 'inherit', - env: { - TEST_PORT: String(webPort), - VITE_SERVER_URL: `http://localhost:${serverPort}`, - }, - }); - await new Promise((resolve) => { - webProcess.on('close', resolve); - }); - - break; - } else if (choice === '2') { - console.log(''); - log('Launching Desktop Application...', 'blue'); - log('(Electron will start its own backend server)', 'yellow'); - console.log(''); - - // Pass selected ports through to Vite + Electron backend - // - TEST_PORT controls Vite dev server port (see apps/ui/vite.config.mts) - // - PORT controls backend server port (see apps/server/src/index.ts) - electronProcess = runNpm(['run', 'dev:electron'], { - stdio: 'inherit', - env: { - TEST_PORT: String(webPort), - PORT: String(serverPort), - VITE_SERVER_URL: `http://localhost:${serverPort}`, - CORS_ORIGIN: corsOriginEnv, - }, - }); - await new Promise((resolve) => { - electronProcess.on('close', resolve); - }); - - break; - } else { - log('Invalid choice. Please enter 1 or 2.', 'red'); - } - } -} - -// Run main function -main().catch((err) => { - console.error(err); - cleanup(); - process.exit(1); -}); diff --git a/package.json b/package.json index e3364964..7772c924 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "node init.mjs", + "dev": "node dev.mjs", "start": "node start.mjs", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs new file mode 100644 index 00000000..af68e452 --- /dev/null +++ b/scripts/launcher-utils.mjs @@ -0,0 +1,647 @@ +/** + * Shared utilities for Automaker launcher scripts (dev.mjs and start.mjs) + * + * This module contains cross-platform utilities for: + * - Process management (ports, killing processes) + * - Terminal output (colors, logging) + * - npm/npx command execution + * - User prompts + * - Health checks + * + * SECURITY NOTE: Uses a restricted fs wrapper that only allows + * operations within a specified base directory. + */ + +import { execSync } from 'child_process'; +import fsNative from 'fs'; +import http from 'http'; +import path from 'path'; +import readline from 'readline'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const treeKill = require('tree-kill'); +const crossSpawn = require('cross-spawn'); + +// ============================================================================= +// Terminal Colors +// ============================================================================= + +export const colors = { + green: '\x1b[0;32m', + blue: '\x1b[0;34m', + yellow: '\x1b[1;33m', + red: '\x1b[0;31m', + reset: '\x1b[0m', +}; + +export const isWindows = process.platform === 'win32'; + +// ============================================================================= +// Restricted fs wrapper - only allows operations within a base directory +// ============================================================================= + +/** + * Create a restricted fs wrapper for a given base directory + * @param {string} baseDir - The base directory to restrict operations to + * @param {string} scriptName - Name of the calling script for error messages + * @returns {object} - Restricted fs operations + */ +export function createRestrictedFs(baseDir, scriptName = 'launcher') { + const normalizedBase = path.resolve(baseDir); + + function validatePath(targetPath) { + const resolved = path.resolve(baseDir, targetPath); + if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { + throw new Error( + `[${scriptName}] Security: Path access denied outside script directory: ${targetPath}` + ); + } + return resolved; + } + + return { + existsSync(targetPath) { + const validated = validatePath(targetPath); + return fsNative.existsSync(validated); + }, + mkdirSync(targetPath, options) { + const validated = validatePath(targetPath); + return fsNative.mkdirSync(validated, options); + }, + createWriteStream(targetPath) { + const validated = validatePath(targetPath); + return fsNative.createWriteStream(validated); + }, + }; +} + +// ============================================================================= +// Logging +// ============================================================================= + +/** + * Print colored output + * @param {string} message - Message to print + * @param {string} color - Color name (green, blue, yellow, red, reset) + */ +export function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +// ============================================================================= +// Command Execution +// ============================================================================= + +/** + * Execute a command synchronously and return stdout + * @param {string} command - Command to execute + * @param {object} options - execSync options + * @returns {string|null} - Command output or null on error + */ +export function execCommand(command, options = {}) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: 'pipe', + ...options, + }).trim(); + } catch { + return null; + } +} + +/** + * Run npm command using cross-spawn for Windows compatibility + * @param {string[]} args - npm command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {ChildProcess} - Spawned process + */ +export function runNpm(args, options = {}, cwd = process.cwd()) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd, + ...restOptions, + env: { + ...process.env, + ...(env || {}), + }, + }; + return crossSpawn('npm', args, spawnOptions); +} + +/** + * Run an npm command and wait for completion + * @param {string[]} args - npm command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {Promise} + */ +export function runNpmAndWait(args, options = {}, cwd = process.cwd()) { + const child = runNpm(args, options, cwd); + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +/** + * Run npx command using cross-spawn for Windows compatibility + * @param {string[]} args - npx command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {ChildProcess} - Spawned process + */ +export function runNpx(args, options = {}, cwd = process.cwd()) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd, + ...restOptions, + env: { + ...process.env, + ...(env || {}), + }, + }; + return crossSpawn('npx', args, spawnOptions); +} + +// ============================================================================= +// Process Management +// ============================================================================= + +/** + * Get process IDs using a specific port (cross-platform) + * @param {number} port - Port number to check + * @returns {number[]} - Array of PIDs using the port + */ +export function getProcessesOnPort(port) { + const pids = new Set(); + + if (isWindows) { + try { + const output = execCommand(`netstat -ano | findstr :${port}`); + if (output) { + const lines = output.split('\n'); + for (const line of lines) { + const match = line.match(/:\d+\s+.*?(\d+)\s*$/); + if (match) { + const pid = parseInt(match[1], 10); + if (pid > 0) pids.add(pid); + } + } + } + } catch { + // Ignore errors + } + } else { + try { + const output = execCommand(`lsof -ti:${port}`); + if (output) { + output.split('\n').forEach((pid) => { + const parsed = parseInt(pid.trim(), 10); + if (parsed > 0) pids.add(parsed); + }); + } + } catch { + // Ignore errors + } + } + + return Array.from(pids); +} + +/** + * Kill a process by PID (cross-platform) + * @param {number} pid - Process ID to kill + * @returns {boolean} - Whether the kill succeeded + */ +export function killProcess(pid) { + try { + if (isWindows) { + execCommand(`taskkill /F /PID ${pid}`); + } else { + process.kill(pid, 'SIGKILL'); + } + return true; + } catch { + return false; + } +} + +/** + * Check if a port is in use (without killing) + * @param {number} port - Port number to check + * @returns {boolean} - Whether the port is in use + */ +export function isPortInUse(port) { + const pids = getProcessesOnPort(port); + return pids.length > 0; +} + +/** + * Kill processes on a port and wait for it to be freed + * @param {number} port - Port number to free + * @returns {Promise} - Whether the port was freed + */ +export async function killPort(port) { + const pids = getProcessesOnPort(port); + + if (pids.length === 0) { + log(`✓ Port ${port} is available`, 'green'); + return true; + } + + log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); + + for (const pid of pids) { + killProcess(pid); + } + + // Wait for port to be freed (max 5 seconds) + for (let i = 0; i < 10; i++) { + await sleep(500); + const remainingPids = getProcessesOnPort(port); + if (remainingPids.length === 0) { + log(`✓ Port ${port} is now free`, 'green'); + return true; + } + } + + log(`Warning: Port ${port} may still be in use`, 'red'); + return false; +} + +/** + * Kill a process tree using tree-kill + * @param {number} pid - Root process ID + * @returns {Promise} + */ +export function killProcessTree(pid) { + return new Promise((resolve) => { + if (!pid) { + resolve(); + return; + } + treeKill(pid, 'SIGTERM', (err) => { + if (err) { + treeKill(pid, 'SIGKILL', () => resolve()); + } else { + resolve(); + } + }); + }); +} + +// ============================================================================= +// Utilities +// ============================================================================= + +/** + * Sleep for a given number of milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if the server health endpoint is responding + * @param {number} port - Server port (default 3008) + * @returns {Promise} - Whether the server is healthy + */ +export function checkHealth(port = 3008) { + return new Promise((resolve) => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * Prompt the user for input + * @param {string} question - Question to ask + * @returns {Promise} - User's answer + */ +export function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ============================================================================= +// Port Configuration Flow +// ============================================================================= + +/** + * Check ports and prompt user for resolution if in use + * @param {object} options - Configuration options + * @param {number} options.defaultWebPort - Default web port (3007) + * @param {number} options.defaultServerPort - Default server port (3008) + * @returns {Promise<{webPort: number, serverPort: number, corsOriginEnv: string}>} + */ +export async function resolvePortConfiguration({ + defaultWebPort = 3007, + defaultServerPort = 3008, +} = {}) { + log(`Checking for processes on ports ${defaultWebPort} and ${defaultServerPort}...`, 'yellow'); + + const webPortInUse = isPortInUse(defaultWebPort); + const serverPortInUse = isPortInUse(defaultServerPort); + + let webPort = defaultWebPort; + let serverPort = defaultServerPort; + + if (webPortInUse || serverPortInUse) { + console.log(''); + if (webPortInUse) { + const pids = getProcessesOnPort(defaultWebPort); + log(`⚠ Port ${defaultWebPort} is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + if (serverPortInUse) { + const pids = getProcessesOnPort(defaultServerPort); + log(`⚠ Port ${defaultServerPort} is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + console.log(''); + + while (true) { + const choice = await prompt( + 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' + ); + const lowerChoice = choice.toLowerCase(); + + if (lowerChoice === 'k' || lowerChoice === 'kill') { + if (webPortInUse) { + await killPort(defaultWebPort); + } else { + log(`✓ Port ${defaultWebPort} is available`, 'green'); + } + if (serverPortInUse) { + await killPort(defaultServerPort); + } else { + log(`✓ Port ${defaultServerPort} is available`, 'green'); + } + break; + } else if (lowerChoice === 'u' || lowerChoice === 'use') { + webPort = await promptForPort('web', defaultWebPort); + serverPort = await promptForPort('server', defaultServerPort, webPort); + log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); + break; + } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { + log('Cancelled.', 'yellow'); + process.exit(0); + } else { + log( + 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', + 'red' + ); + } + } + } else { + log(`✓ Port ${defaultWebPort} is available`, 'green'); + log(`✓ Port ${defaultServerPort} is available`, 'green'); + } + + // Build CORS origin env + const existing = (process.env.CORS_ORIGIN || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => o !== '*'); + const origins = new Set(existing); + origins.add(`http://localhost:${webPort}`); + origins.add(`http://127.0.0.1:${webPort}`); + const corsOriginEnv = Array.from(origins).join(','); + + console.log(''); + + return { webPort, serverPort, corsOriginEnv }; +} + +/** + * Prompt for a specific port with validation + * @param {string} name - Port name (web/server) + * @param {number} defaultPort - Default port value + * @param {number} excludePort - Port to exclude (optional) + * @returns {Promise} + */ +async function promptForPort(name, defaultPort, excludePort = null) { + while (true) { + const input = await prompt(`Enter ${name} port (default ${defaultPort}): `); + const parsed = input.trim() ? parseInt(input.trim(), 10) : defaultPort; + + if (isNaN(parsed) || parsed < 1024 || parsed > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (excludePort && parsed === excludePort) { + log(`${name} port cannot be the same as the other port.`, 'red'); + continue; + } + + if (isPortInUse(parsed)) { + const pids = getProcessesOnPort(parsed); + log(`Port ${parsed} is already in use by process(es): ${pids.join(', ')}`, 'red'); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + return parsed; + } +} + +// ============================================================================= +// UI Components +// ============================================================================= + +/** + * Print the application header banner + * @param {string} title - Header title + */ +export function printHeader(title) { + console.log('╔═══════════════════════════════════════════════════════╗'); + console.log(`║ ${title.padEnd(45)}║`); + console.log('╚═══════════════════════════════════════════════════════╝'); + console.log(''); +} + +/** + * Print the application mode menu + */ +export function printModeMenu() { + console.log('═══════════════════════════════════════════════════════'); + console.log(' Select Application Mode:'); + console.log('═══════════════════════════════════════════════════════'); + console.log(' 1) Web Application (Browser)'); + console.log(' 2) Desktop Application (Electron)'); + console.log('═══════════════════════════════════════════════════════'); + console.log(''); +} + +// ============================================================================= +// Process Cleanup +// ============================================================================= + +/** + * Create a cleanup handler for spawned processes + * @param {object} processes - Object with process references {server, web, electron} + * @returns {Function} - Cleanup function + */ +export function createCleanupHandler(processes) { + return async function cleanup() { + console.log('\nCleaning up...'); + + const killPromises = []; + + if (processes.server && !processes.server.killed && processes.server.pid) { + killPromises.push(killProcessTree(processes.server.pid)); + } + + if (processes.web && !processes.web.killed && processes.web.pid) { + killPromises.push(killProcessTree(processes.web.pid)); + } + + if (processes.electron && !processes.electron.killed && processes.electron.pid) { + killPromises.push(killProcessTree(processes.electron.pid)); + } + + await Promise.all(killPromises); + }; +} + +/** + * Setup signal handlers for graceful shutdown + * @param {Function} cleanup - Cleanup function + */ +export function setupSignalHandlers(cleanup) { + let cleaningUp = false; + + const handleExit = async () => { + if (cleaningUp) return; + cleaningUp = true; + await cleanup(); + process.exit(0); + }; + + process.on('SIGINT', () => handleExit()); + process.on('SIGTERM', () => handleExit()); +} + +// ============================================================================= +// Server Startup +// ============================================================================= + +/** + * Start the backend server and wait for it to be ready + * @param {object} options - Configuration options + * @returns {Promise} - Server process + */ +export async function startServerAndWait({ + serverPort, + corsOriginEnv, + npmArgs, + cwd, + fs, + baseDir, +}) { + log(`Starting backend server on port ${serverPort}...`, 'blue'); + + // Create logs directory + const logsDir = path.join(baseDir, 'logs'); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + const logStream = fs.createWriteStream(path.join(baseDir, 'logs', 'server.log')); + const serverProcess = runNpm( + npmArgs, + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + PORT: String(serverPort), + CORS_ORIGIN: corsOriginEnv, + }, + }, + cwd + ); + + // Pipe to both log file and console + serverProcess.stdout?.on('data', (data) => { + process.stdout.write(data); + logStream.write(data); + }); + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(data); + logStream.write(data); + }); + + log('Waiting for server to be ready...', 'yellow'); + + // Wait for server health check + const maxRetries = 30; + let serverReady = false; + + for (let i = 0; i < maxRetries; i++) { + if (await checkHealth(serverPort)) { + serverReady = true; + break; + } + process.stdout.write('.'); + await sleep(1000); + } + + console.log(''); + + if (!serverReady) { + log('Error: Server failed to start', 'red'); + console.log('Check logs/server.log for details'); + return null; + } + + log('✓ Server is ready!', 'green'); + return serverProcess; +} + +// ============================================================================= +// Dependencies +// ============================================================================= + +/** + * Ensure node_modules exists, install if not + * @param {object} fs - Restricted fs object + * @param {string} baseDir - Base directory + */ +export async function ensureDependencies(fs, baseDir) { + if (!fs.existsSync(path.join(baseDir, 'node_modules'))) { + log('Installing dependencies...', 'blue'); + const install = runNpm(['install'], { stdio: 'inherit' }, baseDir); + await new Promise((resolve, reject) => { + install.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm install failed with code ${code}`)); + }); + }); + } +} diff --git a/start.mjs b/start.mjs index 4d5153fd..0992ccfe 100755 --- a/start.mjs +++ b/start.mjs @@ -3,350 +3,55 @@ /** * Automaker - Production Mode Launch Script * - * This script runs the application in production mode (no dev server). - * It builds everything if needed, then prompts the user to choose web or electron mode. + * This script runs the application in production mode (no Vite dev server). + * It builds everything if needed, then serves static files via vite preview. * - * SECURITY NOTE: This script uses a restricted fs wrapper that only allows - * operations within the script's directory (__dirname). This is a standalone - * launch script that runs before the platform library is available. + * Key differences from dev.mjs: + * - Uses pre-built static files instead of Vite dev server (faster startup) + * - No HMR or hot reloading + * - Server runs from compiled dist/ directory + * - Uses "vite preview" to serve static UI files + * + * Usage: npm run start */ -import { execSync } from 'child_process'; -import fsNative from 'fs'; -import http from 'http'; import path from 'path'; -import readline from 'readline'; import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); -const treeKill = require('tree-kill'); -const crossSpawn = require('cross-spawn'); +import { + createRestrictedFs, + log, + runNpm, + runNpmAndWait, + runNpx, + printHeader, + printModeMenu, + resolvePortConfiguration, + createCleanupHandler, + setupSignalHandlers, + startServerAndWait, + ensureDependencies, + prompt, + killProcessTree, + sleep, +} from './scripts/launcher-utils.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// ============================================================================= -// Restricted fs wrapper - only allows operations within __dirname -// ============================================================================= - -/** - * Validate that a path is within the script's directory - * @param {string} targetPath - Path to validate - * @returns {string} - Resolved path if valid - * @throws {Error} - If path is outside __dirname - */ -function validateScriptPath(targetPath) { - const resolved = path.resolve(__dirname, targetPath); - const normalizedBase = path.resolve(__dirname); - if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { - throw new Error( - `[start.mjs] Security: Path access denied outside script directory: ${targetPath}` - ); - } - return resolved; -} - -/** - * Restricted fs operations - only within script directory - */ -const fs = { - existsSync(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.existsSync(validated); - }, - mkdirSync(targetPath, options) { - const validated = validateScriptPath(targetPath); - return fsNative.mkdirSync(validated, options); - }, - createWriteStream(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.createWriteStream(validated); - }, -}; - -// Colors for terminal output (works on modern terminals including Windows) -const colors = { - green: '\x1b[0;32m', - blue: '\x1b[0;34m', - yellow: '\x1b[1;33m', - red: '\x1b[0;31m', - reset: '\x1b[0m', -}; - -const isWindows = process.platform === 'win32'; +// Create restricted fs for this script's directory +const fs = createRestrictedFs(__dirname, 'start.mjs'); // Track background processes for cleanup -let serverProcess = null; -let webProcess = null; -let electronProcess = null; - -/** - * Print colored output - */ -function log(message, color = 'reset') { - console.log(`${colors[color]}${message}${colors.reset}`); -} - -/** - * Print the header banner - */ -function printHeader() { - console.log('╔═══════════════════════════════════════════════════════╗'); - console.log('║ Automaker Production Mode ║'); - console.log('╚═══════════════════════════════════════════════════════╝'); - console.log(''); -} - -/** - * Execute a command synchronously and return stdout - */ -function execCommand(command, options = {}) { - try { - return execSync(command, { - encoding: 'utf8', - stdio: 'pipe', - ...options, - }).trim(); - } catch { - return null; - } -} - -/** - * Get process IDs using a specific port (cross-platform) - */ -function getProcessesOnPort(port) { - const pids = new Set(); - - if (isWindows) { - // Windows: Use netstat to find PIDs - try { - const output = execCommand(`netstat -ano | findstr :${port}`); - if (output) { - const lines = output.split('\n'); - for (const line of lines) { - // Match lines with LISTENING or ESTABLISHED on our port - const match = line.match(/:\d+\s+.*?(\d+)\s*$/); - if (match) { - const pid = parseInt(match[1], 10); - if (pid > 0) pids.add(pid); - } - } - } - } catch { - // Ignore errors - } - } else { - // Unix: Use lsof - try { - const output = execCommand(`lsof -ti:${port}`); - if (output) { - output.split('\n').forEach((pid) => { - const parsed = parseInt(pid.trim(), 10); - if (parsed > 0) pids.add(parsed); - }); - } - } catch { - // Ignore errors - } - } - - return Array.from(pids); -} - -/** - * Kill a process by PID (cross-platform) - */ -function killProcess(pid) { - try { - if (isWindows) { - execCommand(`taskkill /F /PID ${pid}`); - } else { - process.kill(pid, 'SIGKILL'); - } - return true; - } catch { - return false; - } -} - -/** - * Check if a port is in use (without killing) - */ -function isPortInUse(port) { - const pids = getProcessesOnPort(port); - return pids.length > 0; -} - -/** - * Kill processes on a port and wait for it to be freed - */ -async function killPort(port) { - const pids = getProcessesOnPort(port); - - if (pids.length === 0) { - log(`✓ Port ${port} is available`, 'green'); - return true; - } - - log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); - - for (const pid of pids) { - killProcess(pid); - } - - // Wait for port to be freed (max 5 seconds) - for (let i = 0; i < 10; i++) { - await sleep(500); - const remainingPids = getProcessesOnPort(port); - if (remainingPids.length === 0) { - log(`✓ Port ${port} is now free`, 'green'); - return true; - } - } - - log(`Warning: Port ${port} may still be in use`, 'red'); - return false; -} - -/** - * Sleep for a given number of milliseconds - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Check if the server health endpoint is responding - */ -function checkHealth(port = 3008) { - return new Promise((resolve) => { - const req = http.get(`http://localhost:${port}/api/health`, (res) => { - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.setTimeout(2000, () => { - req.destroy(); - resolve(false); - }); - }); -} - -/** - * Prompt the user for input - */ -function prompt(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -/** - * Run npm command using cross-spawn for Windows compatibility - */ -function runNpm(args, options = {}) { - const { env, ...restOptions } = options; - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...restOptions, - // Ensure environment variables are properly merged with process.env - env: { - ...process.env, - ...(env || {}), - }, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npm', args, spawnOptions); -} - -/** - * Run an npm command and wait for completion - */ -function runNpmAndWait(args, options = {}) { - const child = runNpm(args, options); - return new Promise((resolve, reject) => { - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); - }); - child.on('error', (err) => reject(err)); - }); -} - -/** - * Run npx command using cross-spawn for Windows compatibility - */ -function runNpx(args, options = {}) { - const { env, ...restOptions } = options; - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...restOptions, - // Ensure environment variables are properly merged with process.env - env: { - ...process.env, - ...(env || {}), - }, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npx', args, spawnOptions); -} - -/** - * Kill a process tree using tree-kill - */ -function killProcessTree(pid) { - return new Promise((resolve) => { - if (!pid) { - resolve(); - return; - } - treeKill(pid, 'SIGTERM', (err) => { - if (err) { - // Try force kill if graceful termination fails - treeKill(pid, 'SIGKILL', () => resolve()); - } else { - resolve(); - } - }); - }); -} - -/** - * Cleanup function to kill all spawned processes - */ -async function cleanup() { - console.log('\nCleaning up...'); - - const killPromises = []; - - if (serverProcess && !serverProcess.killed && serverProcess.pid) { - killPromises.push(killProcessTree(serverProcess.pid)); - } - - if (webProcess && !webProcess.killed && webProcess.pid) { - killPromises.push(killProcessTree(webProcess.pid)); - } - - if (electronProcess && !electronProcess.killed && electronProcess.pid) { - killPromises.push(killProcessTree(electronProcess.pid)); - } - - await Promise.all(killPromises); -} +const processes = { + server: null, + web: null, + electron: null, +}; /** * Check if production builds exist + * @returns {{server: boolean, ui: boolean, electron: boolean}} */ function checkBuilds() { const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); @@ -361,31 +66,13 @@ function checkBuilds() { } /** - * Main function + * Build all production artifacts if needed */ -async function main() { - // Change to script directory - process.chdir(__dirname); - - printHeader(); - - // Check if node_modules exists - if (!fs.existsSync(path.join(__dirname, 'node_modules'))) { - log('Installing dependencies...', 'blue'); - const install = runNpm(['install'], { stdio: 'inherit' }); - await new Promise((resolve, reject) => { - install.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm install failed with code ${code}`)); - }); - }); - } - +async function ensureProductionBuilds() { // Always build shared packages first to ensure they're up to date - // (source may have changed even if dist directories exist) log('Building shared packages...', 'blue'); try { - await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname); log('✓ Shared packages built', 'green'); } catch (error) { log(`Failed to build shared packages: ${error.message}`, 'red'); @@ -395,7 +82,11 @@ async function main() { // Always rebuild server to ensure it's in sync with packages log('Building server...', 'blue'); try { - await runNpmAndWait(['run', 'build'], { stdio: 'inherit', cwd: path.join(__dirname, 'apps', 'server') }); + await runNpmAndWait( + ['run', 'build'], + { stdio: 'inherit' }, + path.join(__dirname, 'apps', 'server') + ); log('✓ Server built', 'green'); } catch (error) { log(`Failed to build server: ${error.message}`, 'red'); @@ -410,10 +101,8 @@ async function main() { console.log(''); try { - // Build UI (includes Electron main process) log('Building UI...', 'blue'); - await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }); - + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname); log('✓ Build complete!', 'green'); console.log(''); } catch (error) { @@ -424,155 +113,32 @@ async function main() { log('✓ UI builds found', 'green'); console.log(''); } +} - // Check for processes on required ports and prompt user - log('Checking for processes on ports 3007 and 3008...', 'yellow'); +/** + * Main function + */ +async function main() { + // Change to script directory + process.chdir(__dirname); - const webPortInUse = isPortInUse(3007); - const serverPortInUse = isPortInUse(3008); + printHeader('Automaker Production Mode'); - let webPort = 3007; - let serverPort = 3008; - let corsOriginEnv = process.env.CORS_ORIGIN || ''; + // Ensure dependencies are installed + await ensureDependencies(fs, __dirname); - if (webPortInUse || serverPortInUse) { - console.log(''); - if (webPortInUse) { - const pids = getProcessesOnPort(3007); - log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - if (serverPortInUse) { - const pids = getProcessesOnPort(3008); - log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - console.log(''); + // Build production artifacts if needed + await ensureProductionBuilds(); - while (true) { - const choice = await prompt( - 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' - ); - const lowerChoice = choice.toLowerCase(); + // Resolve port configuration (check/kill/change ports) + const { webPort, serverPort, corsOriginEnv } = await resolvePortConfiguration(); - if (lowerChoice === 'k' || lowerChoice === 'kill') { - if (webPortInUse) { - await killPort(3007); - } else { - log(`✓ Port 3007 is available`, 'green'); - } - if (serverPortInUse) { - await killPort(3008); - } else { - log(`✓ Port 3008 is available`, 'green'); - } - break; - } else if (lowerChoice === 'u' || lowerChoice === 'use') { - // Prompt for new ports - while (true) { - const newWebPort = await prompt('Enter web port (default 3007): '); - const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; - - if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (isPortInUse(parsedWebPort)) { - const pids = getProcessesOnPort(parsedWebPort); - log( - `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - webPort = parsedWebPort; - break; - } - - while (true) { - const newServerPort = await prompt('Enter server port (default 3008): '); - const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; - - if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (parsedServerPort === webPort) { - log('Server port cannot be the same as web port.', 'red'); - continue; - } - - if (isPortInUse(parsedServerPort)) { - const pids = getProcessesOnPort(parsedServerPort); - log( - `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - serverPort = parsedServerPort; - break; - } - - log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); - break; - } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { - log('Cancelled.', 'yellow'); - process.exit(0); - } else { - log( - 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', - 'red' - ); - } - } - } else { - log(`✓ Port 3007 is available`, 'green'); - log(`✓ Port 3008 is available`, 'green'); - } - - // Ensure backend CORS allows whichever UI port we ended up using. - { - const existing = (process.env.CORS_ORIGIN || '') - .split(',') - .map((o) => o.trim()) - .filter(Boolean) - .filter((o) => o !== '*'); - const origins = new Set(existing); - origins.add(`http://localhost:${webPort}`); - origins.add(`http://127.0.0.1:${webPort}`); - corsOriginEnv = Array.from(origins).join(','); - } - console.log(''); - - // Show menu - console.log('═══════════════════════════════════════════════════════'); - console.log(' Select Application Mode:'); - console.log('═══════════════════════════════════════════════════════'); - console.log(' 1) Web Application (Browser)'); - console.log(' 2) Desktop Application (Electron)'); - console.log('═══════════════════════════════════════════════════════'); - console.log(''); + // Show mode selection menu + printModeMenu(); // Setup cleanup handlers - let cleaningUp = false; - const handleExit = async (signal) => { - if (cleaningUp) return; - cleaningUp = true; - await cleanup(); - process.exit(0); - }; - - process.on('SIGINT', () => handleExit('SIGINT')); - process.on('SIGTERM', () => handleExit('SIGTERM')); + const cleanup = createCleanupHandler(processes); + setupSignalHandlers(cleanup); // Prompt for choice while (true) { @@ -582,76 +148,44 @@ async function main() { console.log(''); log('Launching Web Application (Production Mode)...', 'blue'); - // Start the backend server in production mode - log(`Starting backend server on port ${serverPort}...`, 'blue'); - - // Create logs directory - if (!fs.existsSync(path.join(__dirname, 'logs'))) { - fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); - } - - // Start server in background, showing output in console AND logging to file - const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); - serverProcess = runNpm(['run', 'start'], { - stdio: ['ignore', 'pipe', 'pipe'], + // Start the backend server in PRODUCTION mode + // Uses "npm run start" in apps/server which runs the compiled dist/ + // NOT the Vite dev server (no HMR, faster startup) + processes.server = await startServerAndWait({ + serverPort, + corsOriginEnv, + npmArgs: ['run', 'start'], cwd: path.join(__dirname, 'apps', 'server'), - env: { - PORT: String(serverPort), - CORS_ORIGIN: corsOriginEnv, - }, + fs, + baseDir: __dirname, }); - // Pipe to both log file and console - serverProcess.stdout?.on('data', (data) => { - process.stdout.write(data); - logStream.write(data); - }); - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(data); - logStream.write(data); - }); - - log('Waiting for server to be ready...', 'yellow'); - - // Wait for server health check - const maxRetries = 30; - let serverReady = false; - - for (let i = 0; i < maxRetries; i++) { - if (await checkHealth(serverPort)) { - serverReady = true; - break; - } - process.stdout.write('.'); - await sleep(1000); - } - - console.log(''); - - if (!serverReady) { - log('Error: Server failed to start', 'red'); - console.log('Check logs/server.log for details'); + if (!processes.server) { cleanup(); process.exit(1); } - log('✓ Server is ready!', 'green'); log(`Starting web server...`, 'blue'); - // Start vite preview to serve built static files - webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - env: { - VITE_SERVER_URL: `http://localhost:${serverPort}`, + // Start vite preview to serve pre-built static files + // This is NOT Vite dev server - it just serves the dist/ folder + // No HMR, no compilation, just static file serving + processes.web = runNpx( + ['vite', 'preview', '--port', String(webPort)], + { + stdio: 'inherit', + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, }, - }); + path.join(__dirname, 'apps', 'ui') + ); log(`The application is available at: http://localhost:${webPort}`, 'green'); console.log(''); await new Promise((resolve) => { - webProcess.on('close', resolve); + processes.web.on('close', resolve); }); break; @@ -663,7 +197,7 @@ async function main() { // Run electron directly with the built main.js const electronMainPath = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); - + if (!fs.existsSync(electronMainPath)) { log('Error: Electron main process not built. Run build first.', 'red'); process.exit(1); @@ -672,36 +206,43 @@ async function main() { // Start vite preview to serve built static files for electron // (Electron in non-packaged mode needs a server to load from) log('Starting static file server...', 'blue'); - webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { - stdio: ['ignore', 'pipe', 'pipe'], - cwd: path.join(__dirname, 'apps', 'ui'), - env: { - VITE_SERVER_URL: `http://localhost:${serverPort}`, + processes.web = runNpx( + ['vite', 'preview', '--port', String(webPort)], + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, }, - }); + path.join(__dirname, 'apps', 'ui') + ); - // Wait a moment for vite preview to start + // Wait for vite preview to start await sleep(2000); - // Use electron from node_modules - electronProcess = runNpx(['electron', electronMainPath], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - env: { - TEST_PORT: String(webPort), - PORT: String(serverPort), - VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, - VITE_SERVER_URL: `http://localhost:${serverPort}`, - CORS_ORIGIN: corsOriginEnv, - NODE_ENV: 'production', + // Use electron from node_modules with NODE_ENV=production + // This ensures electron loads from the preview server, not Vite dev + processes.electron = runNpx( + ['electron', electronMainPath], + { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + NODE_ENV: 'production', + }, }, - }); + path.join(__dirname, 'apps', 'ui') + ); await new Promise((resolve) => { - electronProcess.on('close', () => { + processes.electron.on('close', () => { // Also kill vite preview when electron closes - if (webProcess && !webProcess.killed && webProcess.pid) { - killProcessTree(webProcess.pid); + if (processes.web && !processes.web.killed && processes.web.pid) { + killProcessTree(processes.web.pid); } resolve(); }); @@ -717,6 +258,7 @@ async function main() { // Run main function main().catch((err) => { console.error(err); + const cleanup = createCleanupHandler(processes); cleanup(); process.exit(1); });