From 597cb9bfaede120ab14a5eff905e1d0d6d5a54a4 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 16 Jan 2026 16:11:53 -0500 Subject: [PATCH] refactor: remove dev.mjs and integrate start-automaker.sh for development mode - Deleted the dev.mjs script, consolidating development mode functionality into start-automaker.sh. - Updated package.json to use start-automaker.sh for the "dev" script and added a "start" script for production mode. - Enhanced start-automaker.sh with production build capabilities and improved argument parsing for better user experience. - Removed launcher-utils.mjs as its functionality has been integrated into start-automaker.sh. --- dev.mjs | 198 ------- package.json | 3 +- scripts/launcher-utils.mjs | 1095 ------------------------------------ start-automaker.sh | 276 +++++++-- 4 files changed, 229 insertions(+), 1343 deletions(-) delete mode 100644 dev.mjs delete mode 100644 scripts/launcher-utils.mjs diff --git a/dev.mjs b/dev.mjs deleted file mode 100644 index 6d137d23..00000000 --- a/dev.mjs +++ /dev/null @@ -1,198 +0,0 @@ -#!/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 { - createRestrictedFs, - log, - runNpm, - runNpmAndWait, - runNpx, - printHeader, - printModeMenu, - resolvePortConfiguration, - createCleanupHandler, - setupSignalHandlers, - startServerAndWait, - ensureDependencies, - prompt, - launchDockerDevContainers, - launchDockerDevServerContainer, -} from './scripts/launcher-utils.mjs'; - -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, - docker: null, -}; - -/** - * Install Playwright browsers (dev-only dependency) - */ -async function installPlaywrightBrowsers() { - log('Checking Playwright browsers...', 'yellow'); - try { - const exitCode = await new Promise((resolve) => { - const playwright = runNpx( - ['playwright', 'install', 'chromium'], - { stdio: 'inherit' }, - 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({ isDev: true }); - - // Setup cleanup handlers - const cleanup = createCleanupHandler(processes); - setupSignalHandlers(cleanup); - - // Prompt for choice - while (true) { - const choice = await prompt('Enter your choice (1, 2, 3, or 4): '); - - 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) { - await 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}`, - VITE_APP_MODE: '1', - }, - }, - __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, - VITE_APP_MODE: '2', - }, - }, - __dirname - ); - - await new Promise((resolve) => { - processes.electron.on('close', resolve); - }); - - break; - } else if (choice === '3') { - console.log(''); - await launchDockerDevContainers({ baseDir: __dirname, processes }); - break; - } else if (choice === '4') { - console.log(''); - await launchDockerDevServerContainer({ baseDir: __dirname, processes }); - break; - } else { - log('Invalid choice. Please enter 1, 2, 3, or 4.', 'red'); - } - } -} - -// Run main function -main().catch(async (err) => { - console.error(err); - const cleanup = createCleanupHandler(processes); - try { - await cleanup(); - } catch (cleanupErr) { - console.error('Cleanup error:', cleanupErr); - } - process.exit(1); -}); diff --git a/package.json b/package.json index a65e869c..7e0b5efe 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "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 dev.mjs", + "dev": "./start-automaker.sh", + "start": "./start-automaker.sh --production", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs deleted file mode 100644 index 1dcdab7f..00000000 --- a/scripts/launcher-utils.mjs +++ /dev/null @@ -1,1095 +0,0 @@ -/** - * Shared utilities for Automaker launcher scripts (dev.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, { statSync } 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 - * @param {object} options - Menu options - * @param {boolean} options.isDev - Whether this is dev mode (changes Docker option description) - */ -export function printModeMenu({ isDev = false } = {}) { - console.log('═══════════════════════════════════════════════════════'); - console.log(' Select Application Mode:'); - console.log('═══════════════════════════════════════════════════════'); - console.log(' 1) Web Application (Browser)'); - console.log(' 2) Desktop Application (Electron)'); - if (isDev) { - console.log(' 3) Docker Container (Dev with Live Reload)'); - console.log(' 4) Electron + Docker API (Local Electron, Container API)'); - } else { - console.log(' 3) Docker Container (Isolated)'); - } - console.log('═══════════════════════════════════════════════════════'); - console.log(''); -} - -// ============================================================================= -// Process Cleanup -// ============================================================================= - -/** - * Create a cleanup handler for spawned processes - * @param {object} processes - Object with process references {server, web, electron, docker} - * @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)); - } - - if (processes.docker && !processes.docker.killed && processes.docker.pid) { - killPromises.push(killProcessTree(processes.docker.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'); - - // Clean up the spawned server process that failed health check - if (serverProcess && !serverProcess.killed && serverProcess.pid) { - log('Terminating failed server process...', 'yellow'); - try { - await killProcessTree(serverProcess.pid); - } catch (killErr) { - // Fallback: try direct kill if tree-kill fails - try { - serverProcess.kill('SIGKILL'); - } catch { - // Process may have already exited - } - } - } - - // Close the log stream - logStream.end(); - - 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}`)); - }); - }); - } -} - -// ============================================================================= -// Docker Utilities -// ============================================================================= - -/** - * Sanitize a project name to be safe for use in shell commands and Docker image names. - * Converts to lowercase and removes any characters that aren't alphanumeric. - * @param {string} name - Project name to sanitize - * @returns {string} - Sanitized project name - */ -export function sanitizeProjectName(name) { - return name.toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -/** - * Get the current git commit SHA - * @param {string} baseDir - Base directory of the git repository - * @returns {string|null} - Current commit SHA or null if not available - */ -export function getCurrentCommitSha(baseDir) { - try { - const sha = execSync('git rev-parse HEAD', { - encoding: 'utf-8', - cwd: baseDir, - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - return sha || null; - } catch { - return null; - } -} - -/** - * Get the commit SHA from a Docker image label - * @param {string} imageName - Docker image name - * @returns {string|null} - Commit SHA from image label or null if not found - */ -export function getImageCommitSha(imageName) { - try { - const labelValue = execSync( - `docker image inspect ${imageName} --format "{{index .Config.Labels \\"automaker.git.commit.sha\\"}}" 2>/dev/null`, - { encoding: 'utf-8' } - ).trim(); - return labelValue && labelValue !== 'unknown' && labelValue !== '' ? labelValue : null; - } catch { - return null; - } -} - -/** - * Check if Docker images need to be rebuilt based on git commit SHA - * Compares the current git commit with the commit SHA stored in the image labels - * @param {string} baseDir - Base directory containing Dockerfile and docker-compose.yml - * @returns {{needsRebuild: boolean, reason: string, currentSha: string|null, imageSha: string|null}} - */ -export function shouldRebuildDockerImages(baseDir) { - try { - // Get current git commit SHA - const currentSha = getCurrentCommitSha(baseDir); - if (!currentSha) { - return { - needsRebuild: true, - reason: 'Could not determine current git commit', - currentSha: null, - imageSha: null, - }; - } - - // Get project name from docker-compose config, falling back to directory name - let projectName; - try { - const composeConfig = execSync('docker compose config --format json', { - encoding: 'utf-8', - cwd: baseDir, - }); - const config = JSON.parse(composeConfig); - projectName = config.name; - } catch { - // Fallback handled below - } - - // Sanitize project name - const sanitizedProjectName = sanitizeProjectName(projectName || path.basename(baseDir)); - const serverImageName = `${sanitizedProjectName}-server`; - const uiImageName = `${sanitizedProjectName}-ui`; - - // Check if images exist - const serverExists = checkImageExists(serverImageName); - const uiExists = checkImageExists(uiImageName); - - if (!serverExists || !uiExists) { - return { - needsRebuild: true, - reason: 'Docker images do not exist', - currentSha, - imageSha: null, - }; - } - - // Get commit SHA from server image (both should have the same) - const imageSha = getImageCommitSha(serverImageName); - - if (!imageSha) { - return { - needsRebuild: true, - reason: 'Docker images have no commit SHA label (legacy build)', - currentSha, - imageSha: null, - }; - } - - // Compare commit SHAs - if (currentSha !== imageSha) { - return { - needsRebuild: true, - reason: `Code changed: ${imageSha.substring(0, 8)} -> ${currentSha.substring(0, 8)}`, - currentSha, - imageSha, - }; - } - - return { - needsRebuild: false, - reason: 'Images are up to date', - currentSha, - imageSha, - }; - } catch (error) { - return { - needsRebuild: true, - reason: 'Could not check Docker image status', - currentSha: null, - imageSha: null, - }; - } -} - -/** - * Check if a Docker image exists - * @param {string} imageName - Docker image name - * @returns {boolean} - Whether the image exists - */ -function checkImageExists(imageName) { - try { - execSync(`docker image inspect ${imageName} 2>/dev/null`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - return true; - } catch { - return false; - } -} - -/** - * Launch Docker containers for development with live reload - * Uses docker-compose.dev.yml which volume mounts the source code - * Also includes docker-compose.override.yml if it exists (for workspace mounts) - * @param {object} options - Configuration options - * @param {string} options.baseDir - Base directory containing docker-compose.dev.yml - * @param {object} options.processes - Processes object to track docker process - * @returns {Promise} - */ -export async function launchDockerDevContainers({ baseDir, processes }) { - log('Launching Docker Container (Development Mode with Live Reload)...', 'blue'); - console.log(''); - - // Check if ANTHROPIC_API_KEY is set - if (!process.env.ANTHROPIC_API_KEY) { - log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); - log('The server will require an API key to function.', 'yellow'); - log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); - console.log(''); - } - - log('Starting development container...', 'yellow'); - log('Source code is volume mounted for live reload', 'yellow'); - log('Running npm install inside container (this may take a moment on first run)...', 'yellow'); - console.log(''); - - // Build compose file arguments - // Start with dev compose file, then add override if it exists - const composeArgs = ['compose', '-f', 'docker-compose.dev.yml']; - - // Check if docker-compose.override.yml exists and include it for workspace mounts - const overridePath = path.join(baseDir, 'docker-compose.override.yml'); - if (fsNative.existsSync(overridePath)) { - composeArgs.push('-f', 'docker-compose.override.yml'); - log('Using docker-compose.override.yml for workspace mount', 'yellow'); - } - - composeArgs.push('up', '--build'); - - // Use docker-compose.dev.yml for development - processes.docker = crossSpawn('docker', composeArgs, { - stdio: 'inherit', - cwd: baseDir, - env: { - ...process.env, - }, - }); - - log('Development container starting...', 'blue'); - log('UI will be available at: http://localhost:3007 (with HMR)', 'green'); - log('API will be available at: http://localhost:3008', 'green'); - console.log(''); - log('Changes to source files will automatically reload.', 'yellow'); - log('Press Ctrl+C to stop the container.', 'yellow'); - - await new Promise((resolve) => { - processes.docker.on('close', resolve); - }); -} - -/** - * Launch only the Docker server container for use with local Electron - * Uses docker-compose.dev-server.yml which only runs the backend API - * Also includes docker-compose.override.yml if it exists (for workspace mounts) - * Automatically launches Electron once the server is healthy. - * @param {object} options - Configuration options - * @param {string} options.baseDir - Base directory containing docker-compose.dev-server.yml - * @param {object} options.processes - Processes object to track docker process - * @returns {Promise} - */ -export async function launchDockerDevServerContainer({ baseDir, processes }) { - log('Launching Docker Server Container + Local Electron...', 'blue'); - console.log(''); - - // Check if ANTHROPIC_API_KEY is set - if (!process.env.ANTHROPIC_API_KEY) { - log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); - log('The server will require an API key to function.', 'yellow'); - log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); - console.log(''); - } - - log('Starting server container...', 'yellow'); - log('Source code is volume mounted for live reload', 'yellow'); - log('Running npm install inside container (this may take a moment on first run)...', 'yellow'); - console.log(''); - - // Build compose file arguments - // Start with dev-server compose file, then add override if it exists - const composeArgs = ['compose', '-f', 'docker-compose.dev-server.yml']; - - // Check if docker-compose.override.yml exists and include it for workspace mounts - const overridePath = path.join(baseDir, 'docker-compose.override.yml'); - if (fsNative.existsSync(overridePath)) { - composeArgs.push('-f', 'docker-compose.override.yml'); - log('Using docker-compose.override.yml for workspace mount', 'yellow'); - } - - composeArgs.push('up', '--build'); - - // Use docker-compose.dev-server.yml for server-only development - // Run with piped stdio so we can still see output but also run Electron - processes.docker = crossSpawn('docker', composeArgs, { - stdio: 'inherit', - cwd: baseDir, - env: { - ...process.env, - }, - }); - - log('Server container starting...', 'blue'); - log('API will be available at: http://localhost:3008', 'green'); - console.log(''); - - // Wait for the server to become healthy - log('Waiting for server to be ready...', 'yellow'); - const serverPort = 3008; - const maxRetries = 120; // 2 minutes (first run may need npm install + build) - let serverReady = false; - - for (let i = 0; i < maxRetries; i++) { - if (await checkHealth(serverPort)) { - serverReady = true; - break; - } - await sleep(1000); - // Show progress dots every 5 seconds - if (i > 0 && i % 5 === 0) { - process.stdout.write('.'); - } - } - - if (!serverReady) { - console.log(''); - log('Error: Server container failed to become healthy', 'red'); - log('Check the Docker logs above for errors', 'red'); - return; - } - - console.log(''); - log('Server is ready! Launching Electron...', 'green'); - console.log(''); - - // Build shared packages before launching Electron - log('Building shared packages...', 'blue'); - try { - await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, baseDir); - } catch (error) { - log('Failed to build packages: ' + error.message, 'red'); - return; - } - - // Launch Electron with SKIP_EMBEDDED_SERVER=true - // This tells Electron to connect to the external Docker server instead of starting its own - processes.electron = crossSpawn('npm', ['run', '_dev:electron'], { - stdio: 'inherit', - cwd: baseDir, - env: { - ...process.env, - SKIP_EMBEDDED_SERVER: 'true', - PORT: '3008', - VITE_SERVER_URL: 'http://localhost:3008', - VITE_APP_MODE: '4', - }, - }); - - log('Electron launched with SKIP_EMBEDDED_SERVER=true', 'green'); - log('Changes to server source files will automatically reload.', 'yellow'); - log('Press Ctrl+C to stop both Electron and the container.', 'yellow'); - console.log(''); - - // Wait for either process to exit - await Promise.race([ - new Promise((resolve) => processes.docker.on('close', resolve)), - new Promise((resolve) => processes.electron.on('close', resolve)), - ]); -} - -/** - * Launch Docker containers with docker-compose (production mode) - * Uses git commit SHA to determine if rebuild is needed - * @param {object} options - Configuration options - * @param {string} options.baseDir - Base directory containing docker-compose.yml - * @param {object} options.processes - Processes object to track docker process - * @returns {Promise} - */ -export async function launchDockerContainers({ baseDir, processes }) { - log('Launching Docker Container (Isolated Mode)...', 'blue'); - - // Check if ANTHROPIC_API_KEY is set - if (!process.env.ANTHROPIC_API_KEY) { - log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); - log('The server will require an API key to function.', 'yellow'); - log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); - console.log(''); - } - - // Check if rebuild is needed based on git commit SHA - const rebuildCheck = shouldRebuildDockerImages(baseDir); - - if (rebuildCheck.needsRebuild) { - log(`Rebuild needed: ${rebuildCheck.reason}`, 'yellow'); - - if (rebuildCheck.currentSha) { - log(`Building images for commit: ${rebuildCheck.currentSha.substring(0, 8)}`, 'blue'); - } - console.log(''); - - // Build with commit SHA label - const buildArgs = ['compose', 'build']; - if (rebuildCheck.currentSha) { - buildArgs.push('--build-arg', `GIT_COMMIT_SHA=${rebuildCheck.currentSha}`); - } - - const buildProcess = crossSpawn('docker', buildArgs, { - stdio: 'inherit', - cwd: baseDir, - }); - - await new Promise((resolve, reject) => { - buildProcess.on('close', (code) => { - if (code !== 0) { - log('Build failed. Exiting.', 'red'); - reject(new Error(`Docker build failed with code ${code}`)); - } else { - log('Build complete. Starting containers...', 'green'); - console.log(''); - resolve(); - } - }); - buildProcess.on('error', (err) => reject(err)); - }); - - // Start containers (already built above) - processes.docker = crossSpawn('docker', ['compose', 'up'], { - stdio: 'inherit', - cwd: baseDir, - env: { - ...process.env, - }, - }); - } else { - log( - `Images are up to date (commit: ${rebuildCheck.currentSha?.substring(0, 8) || 'unknown'})`, - 'green' - ); - log('Starting Docker containers...', 'yellow'); - console.log(''); - - // Start containers without rebuilding - processes.docker = crossSpawn('docker', ['compose', 'up'], { - stdio: 'inherit', - cwd: baseDir, - env: { - ...process.env, - }, - }); - } - - log('Docker containers starting...', 'blue'); - log('UI will be available at: http://localhost:3007', 'green'); - log('API will be available at: http://localhost:3008', 'green'); - console.log(''); - log('Press Ctrl+C to stop the containers.', 'yellow'); - - await new Promise((resolve) => { - processes.docker.on('close', resolve); - }); -} diff --git a/start-automaker.sh b/start-automaker.sh index e18a6631..93c934db 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -64,10 +64,11 @@ C_MUTE="${ESC}[38;5;248m" # Muted gray # ARGUMENT PARSING # ============================================================================ -MODE="${1:-}" +MODE="" USE_COLORS=true CHECK_DEPS=false NO_HISTORY=false +PRODUCTION_MODE=false show_help() { cat << 'EOF' @@ -88,10 +89,13 @@ OPTIONS: --no-colors Disable colored output --check-deps Check dependencies before launching --no-history Don't remember last choice + --production Run in production mode (builds first, faster React) EXAMPLES: - start-automaker.sh # Interactive menu - start-automaker.sh web # Launch web mode directly + start-automaker.sh # Interactive menu (development) + start-automaker.sh --production # Interactive menu (production) + start-automaker.sh web # Launch web mode directly (dev) + start-automaker.sh web --production # Launch web mode (production) start-automaker.sh electron # Launch desktop app directly start-automaker.sh docker # Launch Docker dev container start-automaker.sh --version # Show version @@ -140,6 +144,9 @@ parse_args() { --no-history) NO_HISTORY=true ;; + --production) + PRODUCTION_MODE=true + ;; web|electron|docker|docker-electron) MODE="$1" ;; @@ -241,8 +248,8 @@ check_running_electron() { printf "%${choice_pad}s" "" read -r -p "Choice: " choice - case "${choice,,}" in - k|kill) + case "$choice" in + [kK]|[kK][iI][lL][lL]) echo "" center_print "Killing Electron processes..." "$C_YELLOW" if [ "$IS_WINDOWS" = true ]; then @@ -257,13 +264,13 @@ check_running_electron() { echo "" return 0 ;; - i|ignore) + [iI]|[iI][gG][nN][oO][rR][eE]) echo "" center_print "Continuing without stopping Electron..." "$C_MUTE" echo "" return 0 ;; - c|cancel) + [cC]|[cC][aA][nN][cC][eE][lL]) echo "" center_print "Cancelled." "$C_MUTE" echo "" @@ -308,8 +315,8 @@ check_running_containers() { printf "%${choice_pad}s" "" read -r -p "Choice: " choice - case "${choice,,}" in - s|stop) + case "$choice" in + [sS]|[sS][tT][oO][pP]) echo "" center_print "Stopping existing containers..." "$C_YELLOW" docker compose -f "$compose_file" down 2>/dev/null || true @@ -319,7 +326,7 @@ check_running_containers() { echo "" return 0 # Continue with fresh start ;; - r|restart) + [rR]|[rR][eE][sS][tT][aA][rR][tT]) echo "" center_print "Stopping and rebuilding containers..." "$C_YELLOW" docker compose -f "$compose_file" down 2>/dev/null || true @@ -327,13 +334,13 @@ check_running_containers() { echo "" return 0 # Continue with rebuild ;; - a|attach) + [aA]|[aA][tT][tT][aA][cC][hH]) echo "" center_print "Attaching to existing containers..." "$C_GREEN" echo "" return 2 # Special code for attach ;; - c|cancel) + [cC]|[cC][aA][nN][cC][eE][lL]) echo "" center_print "Cancelled." "$C_MUTE" echo "" @@ -430,7 +437,7 @@ kill_port() { check_ports() { show_cursor - stty echo 2>/dev/null || true + stty echo icanon 2>/dev/null || true local web_in_use=false local server_in_use=false @@ -458,8 +465,8 @@ check_ports() { while true; do read -r -p "What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: " choice - case "${choice,,}" in - k|kill) + case "$choice" in + [kK]|[kK][iI][lL][lL]) if [ "$web_in_use" = true ]; then kill_port "$DEFAULT_WEB_PORT" else @@ -472,7 +479,7 @@ check_ports() { fi break ;; - u|use) + [uU]|[uU][sS][eE]) read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server @@ -480,7 +487,7 @@ check_ports() { echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" break ;; - c|cancel) + [cC]|[cC][aA][nN][cC][eE][lL]) echo "${C_MUTE}Cancelled.${RESET}" exit 0 ;; @@ -496,7 +503,7 @@ check_ports() { fi hide_cursor - stty -echo 2>/dev/null || true + stty -echo -icanon 2>/dev/null || true } validate_terminal_size() { @@ -530,7 +537,12 @@ show_cursor() { cleanup() { show_cursor - stty echo 2>/dev/null || true + # Restore terminal settings (echo and canonical mode) + stty echo icanon 2>/dev/null || true + # Kill server process if running in production mode + if [ -n "${SERVER_PID:-}" ]; then + kill $SERVER_PID 2>/dev/null || true + fi printf "${RESET}\n" } @@ -586,10 +598,16 @@ show_header() { echo -e "${pad}${C_ACC}${l3}${RESET}" echo "" - local sub_display_len=46 + local mode_indicator="" + if [ "$PRODUCTION_MODE" = true ]; then + mode_indicator="${C_GREEN}[PRODUCTION]${RESET}" + else + mode_indicator="${C_YELLOW}[DEVELOPMENT]${RESET}" + fi + local sub_display_len=60 local sub_pad=$(( (TERM_COLS - sub_display_len) / 2 )) printf "%${sub_pad}s" "" - echo -e "${C_MUTE}Autonomous AI Development Studio${RESET} ${C_GRAY}│${RESET} ${C_GREEN}${VERSION}${RESET}" + echo -e "${C_MUTE}Autonomous AI Development Studio${RESET} ${C_GRAY}│${RESET} ${C_GREEN}${VERSION}${RESET} ${mode_indicator}" echo "" echo "" @@ -621,10 +639,10 @@ show_menu() { [[ -z "$sel3" ]] && sel3=" ${C_MUTE}" [[ -z "$sel4" ]] && sel4=" ${C_MUTE}" - printf "%s${border}${sel1}[1]${RESET} 🌐 ${txt1}Web Browser${RESET} ${C_MUTE}localhost:$WEB_PORT${RESET} ${border}\n" "$pad" - printf "%s${border}${sel2}[2]${RESET} 🖥 ${txt2}Desktop App${RESET} ${DIM}Electron${RESET} ${border}\n" "$pad" - printf "%s${border}${sel3}[3]${RESET} 🐳 ${txt3}Docker Dev${RESET} ${DIM}Live Reload${RESET} ${border}\n" "$pad" - printf "%s${border}${sel4}[4]${RESET} 🔗 ${txt4}Electron+Docker${RESET} ${DIM}Local UI, Container API${RESET} ${border}\n" "$pad" + printf "%s${border}${sel1}[1]${RESET} 🌐 ${txt1}Web App${RESET} ${C_MUTE}Server + Browser (localhost:$WEB_PORT)${RESET} ${border}\n" "$pad" + printf "%s${border}${sel2}[2]${RESET} 🖥 ${txt2}Electron${RESET} ${DIM}Desktop App (embedded server)${RESET} ${border}\n" "$pad" + printf "%s${border}${sel3}[3]${RESET} 🐳 ${txt3}Docker${RESET} ${DIM}Full Stack (live reload)${RESET} ${border}\n" "$pad" + printf "%s${border}${sel4}[4]${RESET} 🔗 ${txt4}Electron & Docker${RESET} ${DIM}Desktop + Docker Server${RESET} ${border}\n" "$pad" printf "%s${C_GRAY}├" "$pad" draw_line "─" "$C_GRAY" "$MENU_INNER_WIDTH" @@ -637,7 +655,7 @@ show_menu() { printf "╯${RESET}\n" echo "" - local footer_text="[↑↓] Navigate [Enter] Select [1-4] Jump [Q] Exit" + local footer_text="[↑↓] Navigate [Enter] Select [1-4] Quick Select [Q] Exit" local f_pad=$(( (TERM_COLS - ${#footer_text}) / 2 )) printf "%${f_pad}s" "" echo -e "${DIM}${footer_text}${RESET}" @@ -696,7 +714,7 @@ center_print() { resolve_port_conflicts() { # Ensure terminal is in proper state for input show_cursor - stty echo 2>/dev/null || true + stty echo icanon 2>/dev/null || true local web_in_use=false local server_in_use=false @@ -735,8 +753,8 @@ resolve_port_conflicts() { printf "%${choice_pad}s" "" read -r -p "Choice: " choice - case "${choice,,}" in - k|kill) + case "$choice" in + [kK]|[kK][iI][lL][lL]) echo "" if [ "$web_in_use" = true ]; then center_print "Killing process(es) on port $DEFAULT_WEB_PORT..." "$C_YELLOW" @@ -750,7 +768,7 @@ resolve_port_conflicts() { fi break ;; - u|use) + [uU]|[uU][sS][eE]) echo "" local input_pad=$(( (TERM_COLS - 40) / 2 )) printf "%${input_pad}s" "" @@ -762,7 +780,7 @@ resolve_port_conflicts() { center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN" break ;; - c|cancel) + [cC]|[cC][aA][nN][cC][eE][lL]) echo "" center_print "Cancelled." "$C_MUTE" echo "" @@ -780,7 +798,7 @@ resolve_port_conflicts() { # Restore terminal state hide_cursor - stty -echo 2>/dev/null || true + stty -echo -icanon 2>/dev/null || true } launch_sequence() { @@ -840,12 +858,62 @@ get_last_mode_from_history() { fi } +# ============================================================================ +# PRODUCTION BUILD +# ============================================================================ + +build_for_production() { + echo "" + center_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_GRAY" + center_print "Building for Production" "$C_PRI" + center_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_GRAY" + echo "" + + center_print "Building shared packages..." "$C_YELLOW" + if ! npm run build:packages; then + center_print "✗ Failed to build packages" "$C_RED" + exit 1 + fi + center_print "✓ Packages built" "$C_GREEN" + echo "" + + center_print "Building server..." "$C_YELLOW" + if ! npm run build --workspace=apps/server; then + center_print "✗ Failed to build server" "$C_RED" + exit 1 + fi + center_print "✓ Server built" "$C_GREEN" + echo "" + + center_print "Building UI..." "$C_YELLOW" + if ! npm run build --workspace=apps/ui; then + center_print "✗ Failed to build UI" "$C_RED" + exit 1 + fi + center_print "✓ UI built" "$C_GREEN" + echo "" + + center_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_GRAY" + center_print "Build Complete" "$C_GREEN" + center_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_GRAY" + echo "" +} + +# Ensure production env is applied consistently for builds and runtime +apply_production_env() { + if [ "$PRODUCTION_MODE" = true ]; then + export NODE_ENV="production" + fi +} + # ============================================================================ # MAIN EXECUTION # ============================================================================ parse_args "$@" +apply_production_env + # Pre-flight checks check_platform check_required_commands @@ -856,31 +924,39 @@ if [ "$CHECK_DEPS" = true ]; then fi hide_cursor -stty -echo 2>/dev/null || true +# Disable echo and line buffering for single-key input +stty -echo -icanon 2>/dev/null || true # Function to read a single key, handling escape sequences for arrows +# Note: bash 3.2 (macOS) doesn't support fractional timeouts, so we use a different approach read_key() { local key - local extra + local escape_seq="" if [ -n "$ZSH_VERSION" ]; then read -k 1 -s -t "$INPUT_TIMEOUT" key 2>/dev/null || key="" else - read -n 1 -s -t "$INPUT_TIMEOUT" -r key 2>/dev/null || key="" + # Use IFS= to preserve special characters + IFS= read -n 1 -s -t "$INPUT_TIMEOUT" -r key 2>/dev/null || key="" fi - # Check for escape sequence (arrow keys) + # Check for escape sequence (arrow keys send ESC [ A/B/C/D) if [[ "$key" == $'\x1b' ]]; then - read -n 1 -s -t 0.1 extra 2>/dev/null || extra="" - if [[ "$extra" == "[" ]] || [[ "$extra" == "O" ]]; then - read -n 1 -s -t 0.1 extra 2>/dev/null || extra="" - case "$extra" in - A) echo "UP" ;; - B) echo "DOWN" ;; - *) echo "" ;; + # Read the rest of the escape sequence without timeout + # Arrow keys send 3 bytes: ESC [ A/B/C/D + IFS= read -n 1 -s -r escape_seq 2>/dev/null || escape_seq="" + if [[ "$escape_seq" == "[" ]] || [[ "$escape_seq" == "O" ]]; then + IFS= read -n 1 -s -r escape_seq 2>/dev/null || escape_seq="" + case "$escape_seq" in + A) echo "UP"; return ;; + B) echo "DOWN"; return ;; + C) echo "RIGHT"; return ;; + D) echo "LEFT"; return ;; esac - return fi + # Just ESC key pressed + echo "ESC" + return fi echo "$key" @@ -946,12 +1022,12 @@ esac # Check Docker for Docker modes if [[ "$MODE" == "docker" || "$MODE" == "docker-electron" ]]; then show_cursor - stty echo 2>/dev/null || true + stty echo icanon 2>/dev/null || true if ! check_docker; then exit 1 fi hide_cursor - stty -echo 2>/dev/null || true + stty -echo -icanon 2>/dev/null || true fi # Save to history @@ -962,16 +1038,118 @@ launch_sequence "$MODE_NAME" # Restore terminal state before running npm show_cursor -stty echo 2>/dev/null || true +stty echo icanon 2>/dev/null || true + +# Build for production if needed +if [ "$PRODUCTION_MODE" = true ]; then + build_for_production +fi # Execute the appropriate command case $MODE in web) export TEST_PORT="$WEB_PORT" export VITE_SERVER_URL="http://localhost:$SERVER_PORT" - npm run dev:web + export PORT="$SERVER_PORT" + export CORS_ORIGIN="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + export VITE_APP_MODE="1" + + if [ "$PRODUCTION_MODE" = true ]; then + # Production: run built server and UI preview concurrently + echo "" + center_print "Starting server on port $SERVER_PORT..." "$C_YELLOW" + npm run start --workspace=apps/server & + SERVER_PID=$! + + # Wait for server to be healthy + echo "" + center_print "Waiting for server to be ready..." "$C_YELLOW" + max_retries=30 + server_ready=false + for ((i=0; i /dev/null 2>&1; then + server_ready=true + break + fi + sleep 1 + done + + if [ "$server_ready" = false ]; then + center_print "✗ Server failed to start" "$C_RED" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + center_print "✓ Server is ready!" "$C_GREEN" + echo "" + + # Start UI preview + center_print "Starting UI preview on port $WEB_PORT..." "$C_YELLOW" + npm run preview --workspace=apps/ui -- --port "$WEB_PORT" + + # Cleanup server on exit + kill $SERVER_PID 2>/dev/null || true + else + # Development: build packages, start server, then start UI with Vite dev server + echo "" + center_print "Building shared packages..." "$C_YELLOW" + npm run build:packages + center_print "✓ Packages built" "$C_GREEN" + echo "" + + # Start backend server in dev mode (background) + center_print "Starting backend server on port $SERVER_PORT..." "$C_YELLOW" + npm run _dev:server & + SERVER_PID=$! + + # Wait for server to be healthy + center_print "Waiting for server to be ready..." "$C_YELLOW" + max_retries=30 + server_ready=false + for ((i=0; i /dev/null 2>&1; then + server_ready=true + break + fi + sleep 1 + printf "." + done + echo "" + + if [ "$server_ready" = false ]; then + center_print "✗ Server failed to start" "$C_RED" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + center_print "✓ Server is ready!" "$C_GREEN" + echo "" + + center_print "The application will be available at: http://localhost:$WEB_PORT" "$C_GREEN" + echo "" + + # Start web app with Vite dev server (HMR enabled) + export VITE_APP_MODE="1" + npm run _dev:web + fi ;; electron) + # Set environment variables for Electron (it starts its own server) + export TEST_PORT="$WEB_PORT" + export PORT="$SERVER_PORT" + export VITE_SERVER_URL="http://localhost:$SERVER_PORT" + export CORS_ORIGIN="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + export VITE_APP_MODE="2" + + if [ "$PRODUCTION_MODE" = true ]; then + # For production electron, we'd normally use the packaged app + # For now, run in dev mode but with production-built packages + center_print "Note: For production Electron, use the packaged app" "$C_YELLOW" + center_print "Running with production-built packages..." "$C_MUTE" + echo "" + fi + + center_print "Launching Desktop Application..." "$C_YELLOW" + center_print "(Electron will start its own backend server)" "$C_MUTE" + echo "" npm run dev:electron ;; docker) @@ -1100,7 +1278,7 @@ case $MODE in # Build packages and launch Electron npm run build:packages - SKIP_EMBEDDED_SERVER=true PORT=$DEFAULT_SERVER_PORT VITE_SERVER_URL="http://localhost:$DEFAULT_SERVER_PORT" npm run _dev:electron + SKIP_EMBEDDED_SERVER=true PORT=$DEFAULT_SERVER_PORT VITE_SERVER_URL="http://localhost:$DEFAULT_SERVER_PORT" VITE_APP_MODE="4" npm run _dev:electron # Cleanup docker when electron exits echo ""