mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
- Introduced a .nvmrc file to specify the Node.js version (22) for the project, ensuring consistent development environments. - Enhanced error handling in the startServer function to provide clearer messages when the Node.js executable cannot be found, improving debugging experience. - Updated package.json files across various modules to enforce Node.js version compatibility and ensure consistent dependency versions. These changes aim to streamline development processes and enhance the application's reliability by enforcing version control and improving error reporting.
473 lines
13 KiB
JavaScript
473 lines
13 KiB
JavaScript
#!/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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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/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');
|
|
}
|
|
|
|
// 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, 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'],
|
|
});
|
|
|
|
// 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()) {
|
|
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);
|
|
});
|