From 9fcdd899b240532deb1400cea17a60de33761981 Mon Sep 17 00:00:00 2001 From: Auto Date: Thu, 18 Dec 2025 09:33:35 +0200 Subject: [PATCH] feat: add cross-platform dev script (Windows/macOS/Linux support) Replace Unix-only init.sh with cross-platform init.mjs Node.js script. Changes: - Add init.mjs: Cross-platform Node.js implementation of init.sh - Update package.json: Change dev script from ./init.sh to node init.mjs - Add tree-kill dependency for reliable cross-platform process termination Key features of init.mjs: - Cross-platform port detection (netstat on Windows, lsof on Unix) - Cross-platform process killing using tree-kill package - Uses cross-spawn for reliable npm/npx command execution on Windows - Interactive prompts via Node.js readline module - Colored terminal output (works on modern Windows terminals) - Proper cleanup handlers for Ctrl+C/SIGTERM Bug fix: - Fixed Playwright browser check to run from apps/app directory where @playwright/test is actually installed (was silently failing before) The original init.sh is preserved for backward compatibility. --- init.mjs | 414 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 20 ++- package.json | 5 +- 3 files changed, 428 insertions(+), 11 deletions(-) create mode 100644 init.mjs diff --git a/init.mjs b/init.mjs new file mode 100644 index 00000000..85ab1978 --- /dev/null +++ b/init.mjs @@ -0,0 +1,414 @@ +#!/usr/bin/env node + +/** + * Automaker - Cross-Platform Development Environment Setup and Launch Script + * + * This script works on Windows, macOS, and Linux. + */ + +import { execSync } from 'child_process'; +import fs 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); + +// 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; + } +} + +/** + * 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() { + return new Promise((resolve) => { + const req = http.get('http://localhost:3008/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 spawnOptions = { + stdio: 'inherit', + cwd: __dirname, + ...options, + }; + // cross-spawn handles Windows .cmd files automatically + return crossSpawn('npm', args, spawnOptions); +} + +/** + * 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/app where @playwright/test is installed + log('Checking Playwright browsers...', 'yellow'); + try { + await new Promise((resolve) => { + const playwright = crossSpawn( + 'npx', + ['playwright', 'install', 'chromium'], + { stdio: 'ignore', cwd: path.join(__dirname, 'apps', 'app') } + ); + playwright.on('close', () => resolve()); + playwright.on('error', () => resolve()); + }); + } catch { + // Ignore errors - Playwright install is optional + } + + // Kill any existing processes on required ports + log('Checking for processes on ports 3007 and 3008...', 'yellow'); + await killPort(3007); + await killPort(3008); + 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'); + + // Start the backend server + log('Starting backend server on port 3008...', 'blue'); + + // Create logs directory + if (!fs.existsSync(path.join(__dirname, 'logs'))) { + fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); + } + + // Start server in background + const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); + serverProcess = runNpm(['run', 'dev:server'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + serverProcess.stdout?.pipe(logStream); + serverProcess.stderr?.pipe(logStream); + + 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()) { + 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:3007`, 'green'); + console.log(''); + + // Start web app + webProcess = runNpm(['run', 'dev:web'], { stdio: 'inherit' }); + 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(''); + + electronProcess = runNpm(['run', 'dev:electron'], { stdio: 'inherit' }); + 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-lock.json b/package-lock.json index 8f8487fa..c2386479 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "libs/*" ], "dependencies": { - "cross-spawn": "^7.0.6" + "cross-spawn": "^7.0.6", + "tree-kill": "^1.2.2" } }, "apps/app": { @@ -8223,14 +8224,6 @@ "node": ">=8.0" } }, - "apps/app/node_modules/tree-kill": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "apps/app/node_modules/trim-lines": { "version": "3.0.1", "license": "MIT", @@ -15171,6 +15164,15 @@ "node": ">=6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index fdd557b0..f96b9a3b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,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)})}\"", - "dev": "./init.sh", + "dev": "node init.mjs", "dev:web": "npm run dev:web --workspace=apps/app", "dev:electron": "npm run dev:electron --workspace=apps/app", "dev:electron:debug": "npm run dev:electron:debug --workspace=apps/app", @@ -32,6 +32,7 @@ "lint:lockfile": "! grep -q 'git+ssh://' package-lock.json || (echo 'Error: package-lock.json contains git+ssh:// URLs. Run: git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"' && exit 1)" }, "dependencies": { - "cross-spawn": "^7.0.6" + "cross-spawn": "^7.0.6", + "tree-kill": "^1.2.2" } }