mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: introduce development mode launch script
- Added a new script (dev.mjs) to start the application in development mode with hot reloading using Vite. - The script includes functionality for installing Playwright browsers, resolving port configurations, and launching either a web or desktop application. - Removed the old init.mjs script, which was previously responsible for launching the application. - Updated package.json to reference the new dev.mjs script for the development command. - Introduced a shared utilities module (launcher-utils.mjs) for common functionalities used in both development and production scripts.
This commit is contained in:
@@ -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
|
||||
|
||||
183
dev.mjs
Normal file
183
dev.mjs
Normal file
@@ -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);
|
||||
});
|
||||
649
init.mjs
649
init.mjs
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
647
scripts/launcher-utils.mjs
Normal file
647
scripts/launcher-utils.mjs
Normal file
@@ -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<void>}
|
||||
*/
|
||||
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<boolean>} - 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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<boolean>} - 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<string>} - 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<number>}
|
||||
*/
|
||||
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<ChildProcess>} - 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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
688
start.mjs
688
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user