diff --git a/package.json b/package.json index 1c884bc5..0756f868 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "./start-automaker.sh", - "start": "./start-automaker.sh --production", + "dev": "node start-automaker.mjs", + "start": "node start-automaker.mjs --production", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start-automaker.mjs b/start-automaker.mjs new file mode 100644 index 00000000..97362312 --- /dev/null +++ b/start-automaker.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/** + * Cross-platform launcher for Automaker + * Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux) + */ + +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { platform } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isWindows = platform() === 'win32'; +const args = process.argv.slice(2); + +/** + * Detect the bash variant by checking $OSTYPE + * This is more reliable than path-based detection since bash.exe in PATH + * could be Git Bash, WSL, or something else + * @param {string} bashPath - Path to bash executable + * @returns {'WSL' | 'MSYS' | 'CYGWIN' | 'UNKNOWN'} The detected bash variant + */ +function detectBashVariant(bashPath) { + try { + const result = spawnSync(bashPath, ['-c', 'echo $OSTYPE'], { + stdio: 'pipe', + timeout: 2000, + }); + if (result.status === 0) { + const ostype = result.stdout.toString().trim(); + // WSL reports 'linux-gnu' or similar Linux identifier + if (ostype === 'linux-gnu' || ostype.startsWith('linux')) return 'WSL'; + // MSYS2/Git Bash reports 'msys' or 'mingw*' + if (ostype.startsWith('msys') || ostype.startsWith('mingw')) return 'MSYS'; + // Cygwin reports 'cygwin' + if (ostype.startsWith('cygwin')) return 'CYGWIN'; + } + } catch { + // Fall through to path-based detection + } + // Fallback to path-based detection if $OSTYPE check fails + const lower = bashPath.toLowerCase(); + if (lower.includes('cygwin')) return 'CYGWIN'; + if (lower.includes('system32')) return 'WSL'; + // Default to MSYS (Git Bash) as it's the most common + return 'MSYS'; +} + +/** + * Convert Windows path to Unix-style for the detected bash variant + * @param {string} windowsPath - Windows-style path (e.g., C:\path\to\file) + * @param {string} bashCmd - Path to bash executable (used to detect variant) + * @returns {string} Unix-style path appropriate for the bash variant + */ +function convertPathForBash(windowsPath, bashCmd) { + // Input validation + if (!windowsPath || typeof windowsPath !== 'string') { + throw new Error('convertPathForBash: invalid windowsPath'); + } + if (!bashCmd || typeof bashCmd !== 'string') { + throw new Error('convertPathForBash: invalid bashCmd'); + } + + let unixPath = windowsPath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(unixPath)) { + const drive = unixPath[0].toLowerCase(); + const pathPart = unixPath.slice(2); + + // Detect bash variant via $OSTYPE (more reliable than path-based) + const variant = detectBashVariant(bashCmd); + switch (variant) { + case 'CYGWIN': + // Cygwin expects /cygdrive/c/path format + return `/cygdrive/${drive}${pathPart}`; + case 'WSL': + // WSL expects /mnt/c/path format + return `/mnt/${drive}${pathPart}`; + case 'MSYS': + default: + // MSYS2/Git Bash expects /c/path format + return `/${drive}${pathPart}`; + } + } + return unixPath; +} + +/** + * Find bash executable on Windows + */ +function findBashOnWindows() { + const possiblePaths = [ + // Git Bash (most common) + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // MSYS2 + 'C:\\msys64\\usr\\bin\\bash.exe', + 'C:\\msys32\\usr\\bin\\bash.exe', + // Cygwin + 'C:\\cygwin64\\bin\\bash.exe', + 'C:\\cygwin\\bin\\bash.exe', + // WSL bash (available in PATH on Windows 10+) + 'bash.exe', + ]; + + for (const bashPath of possiblePaths) { + if (bashPath === 'bash.exe') { + // Check if bash is in PATH + try { + const result = spawnSync('where', ['bash.exe'], { stdio: 'pipe' }); + if (result?.status === 0) { + return 'bash.exe'; + } + } catch (err) { + // where command failed, continue checking other paths + } + } else if (existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Run the bash script + */ +function runBashScript() { + const scriptPath = join(__dirname, 'start-automaker.sh'); + + if (!existsSync(scriptPath)) { + console.error('Error: start-automaker.sh not found'); + process.exit(1); + } + + let bashCmd; + let bashArgs; + + if (isWindows) { + bashCmd = findBashOnWindows(); + + if (!bashCmd) { + console.error('Error: Could not find bash on Windows.'); + console.error('Please install Git for Windows from https://git-scm.com/download/win'); + console.error(''); + console.error('Alternatively, you can run these commands directly:'); + console.error(' npm run dev:web - Web browser mode'); + console.error(' npm run dev:electron - Desktop app mode'); + process.exit(1); + } + + // Convert Windows path to appropriate Unix-style for the detected bash variant + const unixPath = convertPathForBash(scriptPath, bashCmd); + bashArgs = [unixPath, ...args]; + } else { + bashCmd = '/bin/bash'; + bashArgs = [scriptPath, ...args]; + } + + const child = spawn(bashCmd, bashArgs, { + stdio: 'inherit', + env: { + ...process.env, + // Ensure proper terminal handling + TERM: process.env.TERM || 'xterm-256color', + }, + // shell: false ensures signals are forwarded directly to the child process + shell: false, + }); + + child.on('error', (err) => { + if (err.code === 'ENOENT') { + console.error(`Error: Could not find bash at "${bashCmd}"`); + console.error('Please ensure Git Bash or another bash shell is installed.'); + } else { + console.error('Error launching Automaker:', err.message); + } + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + // Process was killed by a signal - exit with 1 to indicate abnormal termination + // (Unix convention is 128 + signal number, but we use 1 for simplicity) + process.exit(1); + } + process.exit(code ?? 0); + }); + + // Forward signals to child process (guard against race conditions) + process.on('SIGINT', () => { + if (!child.killed) child.kill('SIGINT'); + }); + process.on('SIGTERM', () => { + if (!child.killed) child.kill('SIGTERM'); + }); +} + +runBashScript(); diff --git a/start-automaker.sh b/start-automaker.sh index 94f143ab..ee273348 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -37,6 +37,32 @@ DEFAULT_SERVER_PORT=3008 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT +# Port validation function +# Returns 0 if valid, 1 if invalid (with error message printed) +validate_port() { + local port="$1" + local port_name="${2:-port}" + + # Check if port is a number + if ! [[ "$port" =~ ^[0-9]+$ ]]; then + echo "${C_RED}Error:${RESET} $port_name must be a number, got '$port'" + return 1 + fi + + # Check if port is in valid range (1-65535) + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "${C_RED}Error:${RESET} $port_name must be between 1-65535, got '$port'" + return 1 + fi + + # Check if port is in privileged range (warning only) + if [ "$port" -lt 1024 ]; then + echo "${C_YELLOW}Warning:${RESET} $port_name $port is in privileged range (requires root/admin)" + fi + + return 0 +} + # Hostname configuration # Use VITE_HOSTNAME if explicitly set, otherwise default to localhost # Note: Don't use $HOSTNAME as it's a bash built-in containing the machine's hostname @@ -509,10 +535,23 @@ check_ports() { break ;; [uU]|[uU][sS][eE]) + # Collect both ports first read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" break ;; @@ -800,12 +839,25 @@ resolve_port_conflicts() { [uU]|[uU][sS][eE]) echo "" local input_pad=$(( (TERM_COLS - 40) / 2 )) + # Collect both ports first printf "%${input_pad}s" "" read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} printf "%${input_pad}s" "" read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN" break ;;