From 019d6dd7bd8c6fe6609a635d40dbb902a3317127 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 22:50:42 -0500 Subject: [PATCH 1/8] fix memory leak --- TODO.md | 17 ++ apps/ui/src/app.tsx | 13 +- package.json | 1 + start.mjs | 722 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 TODO.md create mode 100755 start.mjs diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..3771806b --- /dev/null +++ b/TODO.md @@ -0,0 +1,17 @@ +# Bugs + +- Setting the default model does not seem like it works. + +# UX + +- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff +- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex. +- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live +- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card. +- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them. +- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time. +- Typing in the text area of the plan mode was super laggy. +- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something. +- modals are not scrollable if height of the screen is small enough +- and the Agent Runner add an archival button for the new sessions. +- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue. diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 50380095..c14ab6d0 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { RouterProvider } from '@tanstack/react-router'; import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; @@ -15,6 +15,17 @@ export default function App() { return true; }); + // Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode + // React's internal scheduler creates performance marks/measures that accumulate without cleanup + useEffect(() => { + const clearPerfEntries = () => { + performance.clearMarks(); + performance.clearMeasures(); + }; + const interval = setInterval(clearPerfEntries, 5000); + return () => clearInterval(interval); + }, []); + // Run settings migration on startup (localStorage -> file storage) const migrationState = useSettingsMigration(); if (migrationState.migrated) { diff --git a/package.json b/package.json index bb9c7efa..e3364964 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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", + "start": "node start.mjs", "_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.mjs b/start.mjs new file mode 100755 index 00000000..4d5153fd --- /dev/null +++ b/start.mjs @@ -0,0 +1,722 @@ +#!/usr/bin/env node + +/** + * 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. + * + * 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( + `[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'; + +// 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); +} + +/** + * Check if production builds exist + */ +function checkBuilds() { + const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); + const uiDist = path.join(__dirname, 'apps', 'ui', 'dist'); + const electronDist = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); + + return { + server: fs.existsSync(serverDist), + ui: fs.existsSync(uiDist), + electron: fs.existsSync(electronDist), + }; +} + +/** + * 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}`)); + }); + }); + } + + // 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' }); + log('✓ Shared packages built', 'green'); + } catch (error) { + log(`Failed to build shared packages: ${error.message}`, 'red'); + process.exit(1); + } + + // 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') }); + log('✓ Server built', 'green'); + } catch (error) { + log(`Failed to build server: ${error.message}`, 'red'); + process.exit(1); + } + + // Check if UI/Electron builds exist (these are slower, so only build if missing) + const builds = checkBuilds(); + + if (!builds.ui || !builds.electron) { + log('UI/Electron builds not found. Building...', 'yellow'); + console.log(''); + + try { + // Build UI (includes Electron main process) + log('Building UI...', 'blue'); + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }); + + log('✓ Build complete!', 'green'); + console.log(''); + } catch (error) { + log(`Build failed: ${error.message}`, 'red'); + process.exit(1); + } + } else { + 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'); + + 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. + { + 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 (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'], + cwd: path.join(__dirname, 'apps', 'server'), + env: { + PORT: String(serverPort), + CORS_ORIGIN: corsOriginEnv, + }, + }); + + // 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'); + 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}`, + }, + }); + + log(`The application is available at: http://localhost:${webPort}`, 'green'); + console.log(''); + + await new Promise((resolve) => { + webProcess.on('close', resolve); + }); + + break; + } else if (choice === '2') { + console.log(''); + log('Launching Desktop Application (Production Mode)...', 'blue'); + log('(Electron will start its own backend server)', 'yellow'); + console.log(''); + + // 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); + } + + // 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}`, + }, + }); + + // Wait a moment 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', + }, + }); + + await new Promise((resolve) => { + electronProcess.on('close', () => { + // Also kill vite preview when electron closes + if (webProcess && !webProcess.killed && webProcess.pid) { + killProcessTree(webProcess.pid); + } + 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); +}); From e32a82cca5442bcc5a814781f08cf34086b7cfa4 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:00:20 -0500 Subject: [PATCH 2/8] refactor: remove MCP permission settings and streamline SDK options for autonomous mode - Removed MCP permission settings from the application, including related functions and UI components. - Updated SDK options to always bypass permissions and allow unrestricted tool access in autonomous mode. - Adjusted related components and services to reflect the removal of MCP permission configurations, ensuring a cleaner and more efficient codebase. --- apps/server/src/lib/sdk-options.ts | 48 ++++------ apps/server/src/lib/settings-helpers.ts | 35 ------- apps/server/src/providers/claude-provider.ts | 22 ++--- .../routes/enhance-prompt/routes/enhance.ts | 4 +- .../routes/features/routes/generate-title.ts | 4 +- apps/server/src/services/agent-service.ts | 8 -- apps/server/src/services/auto-mode-service.ts | 14 --- .../tests/unit/lib/settings-helpers.test.ts | 91 +----------------- .../mcp-servers/components/index.ts | 1 - .../components/mcp-permission-settings.tsx | 96 ------------------- .../mcp-servers/hooks/use-mcp-servers.ts | 15 +-- .../mcp-servers/mcp-servers-section.tsx | 20 +--- apps/ui/src/hooks/use-settings-migration.ts | 6 +- apps/ui/src/lib/http-api-client.ts | 2 - apps/ui/src/store/app-store.ts | 21 ---- libs/types/src/provider.ts | 2 - libs/types/src/settings.ts | 8 -- 17 files changed, 36 insertions(+), 361 deletions(-) delete mode 100644 apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index d9b78398..59aa4c60 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -252,10 +252,14 @@ export function getModelForUseCase( /** * Base options that apply to all SDK calls + * + * AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations + * for fully autonomous operation without user prompts. */ function getBaseOptions(): Partial { return { - permissionMode: 'acceptEdits', + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }; } @@ -276,31 +280,27 @@ interface McpPermissionOptions { * Centralizes the logic for determining permission modes and tool restrictions * when MCP servers are configured. * + * AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation. + * Always allow unrestricted tools when MCP servers are configured. + * * @param config - The SDK options config * @returns Object with MCP permission settings to spread into final options */ function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0; - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const mcpAutoApprove = config.mcpAutoApproveTools ?? true; - const mcpUnrestricted = config.mcpUnrestrictedTools ?? true; - // Determine if we should bypass permissions based on settings - const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; - // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) - const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; + // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools + // Only restrict tools when no MCP servers are configured + const shouldRestrictTools = !hasMcpServers; return { shouldRestrictTools, - // Only include bypass options when MCP is configured and auto-approve is enabled - bypassOptions: shouldBypassPermissions - ? { - permissionMode: 'bypassPermissions' as const, - // Required flag when using bypassPermissions mode - allowDangerouslySkipPermissions: true, - } - : {}, + // AUTONOMOUS MODE: Always include bypass options (though base options already set this) + bypassOptions: { + permissionMode: 'bypassPermissions' as const, + // Required flag when using bypassPermissions mode + allowDangerouslySkipPermissions: true, + }, // Include MCP servers if configured mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {}, }; @@ -392,12 +392,6 @@ export interface CreateSdkOptionsConfig { /** MCP servers to make available to the agent */ mcpServers?: Record; - - /** Auto-approve MCP tool calls without permission prompts */ - mcpAutoApproveTools?: boolean; - - /** Allow unrestricted tools when MCP servers are enabled */ - mcpUnrestrictedTools?: boolean; } // Re-export MCP types from @automaker/types for convenience @@ -426,10 +420,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt return { ...getBaseOptions(), - // Override permissionMode - spec generation only needs read-only tools - // Using "acceptEdits" can cause Claude to write files to unexpected locations - // See: https://github.com/AutoMaker-Org/automaker/issues/149 - permissionMode: 'default', + // AUTONOMOUS MODE: Base options already set bypassPermissions and allowDangerouslySkipPermissions model: getModelForUseCase('spec', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, @@ -458,8 +449,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): return { ...getBaseOptions(), - // Override permissionMode - feature generation only needs read-only tools - permissionMode: 'default', + // AUTONOMOUS MODE: Base options already set bypassPermissions and allowDangerouslySkipPermissions model: getModelForUseCase('features', config.model), maxTurns: MAX_TURNS.quick, cwd: config.cwd, diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index b6e86ff2..9a322994 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -191,41 +191,6 @@ export async function getMCPServersFromSettings( } } -/** - * Get MCP permission settings from global settings. - * - * @param settingsService - Optional settings service instance - * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') - * @returns Promise resolving to MCP permission settings - */ -export async function getMCPPermissionSettings( - settingsService?: SettingsService | null, - logPrefix = '[SettingsHelper]' -): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> { - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true }; - - if (!settingsService) { - return defaults; - } - - try { - const globalSettings = await settingsService.getGlobalSettings(); - const result = { - mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true, - mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true, - }; - logger.info( - `${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}` - ); - return result; - } catch (error) { - logger.error(`${logPrefix} Failed to load MCP permission settings:`, error); - return defaults; - } -} - /** * Convert a settings MCPServerConfig to SDK McpServerConfig format. * Validates required fields and throws informative errors if missing. diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 33494535..f61db202 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -63,20 +63,13 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options - // MCP permission logic - determines how to handle tool permissions when MCP servers are configured. - // This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since - // the provider is the final point where SDK options are constructed. + // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const mcpAutoApprove = options.mcpAutoApproveTools ?? true; - const mcpUnrestricted = options.mcpUnrestrictedTools ?? true; const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; - // Determine permission mode based on settings - const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; - // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) - const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; + // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools + // Only restrict tools when no MCP servers are configured + const shouldRestrictTools = !hasMcpServers; const sdkOptions: Options = { model, @@ -88,10 +81,9 @@ export class ClaudeProvider extends BaseProvider { // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), - // When MCP servers are configured and auto-approve is enabled, use bypassPermissions - permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default', - // Required when using bypassPermissions mode - ...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }), + // AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, abortController, // Resume existing SDK session if we have a session ID ...(sdkSessionId && conversationHistory && conversationHistory.length > 0 diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index ad6e9602..744a67b0 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -164,7 +164,9 @@ export function createEnhanceHandler( systemPrompt, maxTurns: 1, allowedTools: [], - permissionMode: 'acceptEdits', + // AUTONOMOUS MODE: Always bypass permissions + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }, }); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 1225a825..49c59801 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -96,7 +96,9 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P systemPrompt: SYSTEM_PROMPT, maxTurns: 1, allowedTools: [], - permissionMode: 'acceptEdits', + // AUTONOMOUS MODE: Always bypass permissions + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }, }); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index c507d81b..6fbe7744 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -23,7 +23,6 @@ import { getEnableSandboxModeSetting, filterClaudeMdFromContext, getMCPServersFromSettings, - getMCPPermissionSettings, getPromptCustomization, } from '../lib/settings-helpers.js'; @@ -235,9 +234,6 @@ export class AgentService { // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); - // Load MCP permission settings (global setting only) - const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]'); - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -264,8 +260,6 @@ export class AgentService { autoLoadClaudeMd, enableSandboxMode, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -290,8 +284,6 @@ export class AgentService { sandbox: sdkOptions.sandbox, // Pass sandbox configuration sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 54f2f8f1..a4e62778 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -38,7 +38,6 @@ import { getEnableSandboxModeSetting, filterClaudeMdFromContext, getMCPServersFromSettings, - getMCPPermissionSettings, getPromptCustomization, } from '../lib/settings-helpers.js'; @@ -2003,9 +2002,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); - // Load MCP permission settings (global setting only) - const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]'); - // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir, @@ -2014,8 +2010,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. autoLoadClaudeMd, enableSandboxMode, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -2058,8 +2052,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. settingSources: sdkOptions.settingSources, sandbox: sdkOptions.sandbox, // Pass sandbox configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Execute via provider @@ -2291,8 +2283,6 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let revisionText = ''; @@ -2431,8 +2421,6 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let taskOutput = ''; @@ -2523,8 +2511,6 @@ Implement all the changes described in the plan above.`; allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); for await (const msg of continuationStream) { diff --git a/apps/server/tests/unit/lib/settings-helpers.test.ts b/apps/server/tests/unit/lib/settings-helpers.test.ts index 8af48580..a7096c55 100644 --- a/apps/server/tests/unit/lib/settings-helpers.test.ts +++ b/apps/server/tests/unit/lib/settings-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js'; +import { getMCPServersFromSettings } from '@/lib/settings-helpers.js'; import type { SettingsService } from '@/services/settings-service.js'; // Mock the logger @@ -286,93 +286,4 @@ describe('settings-helpers.ts', () => { }); }); }); - - describe('getMCPPermissionSettings', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return defaults when settingsService is null', async () => { - const result = await getMCPPermissionSettings(null); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - }); - - it('should return defaults when settingsService is undefined', async () => { - const result = await getMCPPermissionSettings(undefined); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - }); - - it('should return settings from service', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({ - mcpAutoApproveTools: false, - mcpUnrestrictedTools: false, - }), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService); - expect(result).toEqual({ - mcpAutoApproveTools: false, - mcpUnrestrictedTools: false, - }); - }); - - it('should default to true when settings are undefined', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({}), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - }); - - it('should handle mixed settings', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: false, - }), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: false, - }); - }); - - it('should return defaults and log error on exception', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService, '[Test]'); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - // Logger will be called with error, but we don't need to assert it - }); - - it('should use custom log prefix', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }), - } as unknown as SettingsService; - - await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]'); - // Logger will be called with custom prefix, but we don't need to assert it - }); - }); }); diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts index db49d81d..6903ba40 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts @@ -1,4 +1,3 @@ export { MCPServerHeader } from './mcp-server-header'; -export { MCPPermissionSettings } from './mcp-permission-settings'; export { MCPToolsWarning } from './mcp-tools-warning'; export { MCPServerCard } from './mcp-server-card'; diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx deleted file mode 100644 index e65e25bb..00000000 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { ShieldAlert } from 'lucide-react'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { syncSettingsToServer } from '@/hooks/use-settings-migration'; -import { cn } from '@/lib/utils'; - -interface MCPPermissionSettingsProps { - mcpAutoApproveTools: boolean; - mcpUnrestrictedTools: boolean; - onAutoApproveChange: (checked: boolean) => void; - onUnrestrictedChange: (checked: boolean) => void; -} - -export function MCPPermissionSettings({ - mcpAutoApproveTools, - mcpUnrestrictedTools, - onAutoApproveChange, - onUnrestrictedChange, -}: MCPPermissionSettingsProps) { - const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools; - - return ( -
-
-
- { - onAutoApproveChange(checked); - await syncSettingsToServer(); - }} - data-testid="mcp-auto-approve-toggle" - className="mt-0.5" - /> -
- -

- When enabled, the AI agent can use MCP tools without permission prompts. -

- {mcpAutoApproveTools && ( -

- - Bypasses normal permission checks -

- )} -
-
- -
- { - onUnrestrictedChange(checked); - await syncSettingsToServer(); - }} - data-testid="mcp-unrestricted-toggle" - className="mt-0.5" - /> -
- -

- When enabled, the AI agent can use any tool, not just the default set. -

- {mcpUnrestrictedTools && ( -

- - Agent has full tool access including file writes and bash -

- )} -
-
- - {hasAnyEnabled && ( -
-

Security Note

-

- These settings reduce security restrictions for MCP tool usage. Only enable if you - trust all configured MCP servers. -

-
- )} -
-
- ); -} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts b/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts index a6cd83b4..615aa657 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts +++ b/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts @@ -21,16 +21,7 @@ interface PendingServerData { } export function useMCPServers() { - const { - mcpServers, - addMCPServer, - updateMCPServer, - removeMCPServer, - mcpAutoApproveTools, - mcpUnrestrictedTools, - setMcpAutoApproveTools, - setMcpUnrestrictedTools, - } = useAppStore(); + const { mcpServers, addMCPServer, updateMCPServer, removeMCPServer } = useAppStore(); // State const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); @@ -938,10 +929,6 @@ export function useMCPServers() { return { // Store state mcpServers, - mcpAutoApproveTools, - mcpUnrestrictedTools, - setMcpAutoApproveTools, - setMcpUnrestrictedTools, // Dialog state isAddDialogOpen, diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx index 0cec3af4..5c06adbe 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx @@ -1,12 +1,7 @@ import { Plug } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useMCPServers } from './hooks'; -import { - MCPServerHeader, - MCPPermissionSettings, - MCPToolsWarning, - MCPServerCard, -} from './components'; +import { MCPServerHeader, MCPToolsWarning, MCPServerCard } from './components'; import { AddEditServerDialog, DeleteServerDialog, @@ -20,10 +15,6 @@ export function MCPServersSection() { const { // Store state mcpServers, - mcpAutoApproveTools, - mcpUnrestrictedTools, - setMcpAutoApproveTools, - setMcpUnrestrictedTools, // Dialog state isAddDialogOpen, @@ -98,15 +89,6 @@ export function MCPServersSection() { onAdd={handleOpenAddDialog} /> - {mcpServers.length > 0 && ( - - )} - {showToolsWarning && }
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 7abc86c2..3f7df977 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -230,8 +230,6 @@ export async function syncSettingsToServer(): Promise { keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, - mcpAutoApproveTools: state.mcpAutoApproveTools, - mcpUnrestrictedTools: state.mcpUnrestrictedTools, promptCustomization: state.promptCustomization, projects: state.projects, trashedProjects: state.trashedProjects, @@ -336,12 +334,10 @@ export async function loadMCPServersFromServer(): Promise { } const mcpServers = result.settings.mcpServers || []; - const mcpAutoApproveTools = result.settings.mcpAutoApproveTools ?? true; - const mcpUnrestrictedTools = result.settings.mcpUnrestrictedTools ?? true; // Clear existing and add all from server // We need to update the store directly since we can't use hooks here - useAppStore.setState({ mcpServers, mcpAutoApproveTools, mcpUnrestrictedTools }); + useAppStore.setState({ mcpServers }); console.log(`[Settings Load] Loaded ${mcpServers.length} MCP servers from server`); return true; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 32bd88f8..93ed4317 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1438,8 +1438,6 @@ export class HttpApiClient implements ElectronAPI { headers?: Record; enabled?: boolean; }>; - mcpAutoApproveTools?: boolean; - mcpUnrestrictedTools?: boolean; }; error?: string; }> => this.get('/api/settings/global'), diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index a57e4d93..ac0ba291 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -491,8 +491,6 @@ export interface AppState { // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use - mcpAutoApproveTools: boolean; // Auto-approve MCP tool calls without permission prompts - mcpUnrestrictedTools: boolean; // Allow unrestricted tools when MCP servers are enabled // Prompt Customization promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement @@ -777,8 +775,6 @@ export interface AppActions { setAutoLoadClaudeMd: (enabled: boolean) => Promise; setEnableSandboxMode: (enabled: boolean) => Promise; setSkipSandboxWarning: (skip: boolean) => Promise; - setMcpAutoApproveTools: (enabled: boolean) => Promise; - setMcpUnrestrictedTools: (enabled: boolean) => Promise; // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; @@ -980,8 +976,6 @@ const initialState: AppState = { enableSandboxMode: false, // Default to disabled (can be enabled for additional security) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default - mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools - mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled promptCustomization: {}, // Empty by default - all prompts use built-in defaults aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, @@ -1632,19 +1626,6 @@ export const useAppStore = create()( const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, - setMcpAutoApproveTools: async (enabled) => { - set({ mcpAutoApproveTools: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - setMcpUnrestrictedTools: async (enabled) => { - set({ mcpUnrestrictedTools: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - // Prompt Customization actions setPromptCustomization: async (customization) => { set({ promptCustomization: customization }); @@ -2933,8 +2914,6 @@ export const useAppStore = create()( skipSandboxWarning: state.skipSandboxWarning, // MCP settings mcpServers: state.mcpServers, - mcpAutoApproveTools: state.mcpAutoApproveTools, - mcpUnrestrictedTools: state.mcpUnrestrictedTools, // Prompt customization promptCustomization: state.promptCustomization, // Profiles and sessions diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 917b8491..c053da31 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -71,8 +71,6 @@ export interface ExecuteOptions { maxTurns?: number; allowedTools?: string[]; mcpServers?: Record; - mcpAutoApproveTools?: boolean; // Auto-approve MCP tool calls without permission prompts - mcpUnrestrictedTools?: boolean; // Allow unrestricted tools when MCP servers are enabled abortController?: AbortController; conversationHistory?: ConversationMessage[]; // Previous messages for context sdkSessionId?: string; // Claude SDK session ID for resuming conversations diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 309703ce..cc4b7f7c 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -359,10 +359,6 @@ export interface GlobalSettings { // MCP Server Configuration /** List of configured MCP servers for agent use */ mcpServers: MCPServerConfig[]; - /** Auto-approve MCP tool calls without permission prompts (uses bypassPermissions mode) */ - mcpAutoApproveTools?: boolean; - /** Allow unrestricted tools when MCP servers are enabled (don't filter allowedTools) */ - mcpUnrestrictedTools?: boolean; // Prompt Customization /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ @@ -535,10 +531,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { enableSandboxMode: false, skipSandboxWarning: false, mcpServers: [], - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, }; /** Default credentials (empty strings - user must provide API keys) */ From 9552670d3d02aa5a1530275fb73525009988435e Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:11:18 -0500 Subject: [PATCH 3/8] 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. --- .husky/pre-commit | 35 +- dev.mjs | 183 ++++++++++ init.mjs | 649 ---------------------------------- package.json | 2 +- scripts/launcher-utils.mjs | 647 ++++++++++++++++++++++++++++++++++ start.mjs | 688 +++++++------------------------------ 6 files changed, 980 insertions(+), 1224 deletions(-) create mode 100644 dev.mjs delete mode 100644 init.mjs create mode 100644 scripts/launcher-utils.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..812732d5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 diff --git a/dev.mjs b/dev.mjs new file mode 100644 index 00000000..f2ad01cc --- /dev/null +++ b/dev.mjs @@ -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); +}); diff --git a/init.mjs b/init.mjs deleted file mode 100644 index 49d47fa6..00000000 --- a/init.mjs +++ /dev/null @@ -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); -}); diff --git a/package.json b/package.json index e3364964..7772c924 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs new file mode 100644 index 00000000..af68e452 --- /dev/null +++ b/scripts/launcher-utils.mjs @@ -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} + */ +export function runNpmAndWait(args, options = {}, cwd = process.cwd()) { + const child = runNpm(args, options, cwd); + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +/** + * Run npx command using cross-spawn for Windows compatibility + * @param {string[]} args - npx command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {ChildProcess} - Spawned process + */ +export function runNpx(args, options = {}, cwd = process.cwd()) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd, + ...restOptions, + env: { + ...process.env, + ...(env || {}), + }, + }; + return crossSpawn('npx', args, spawnOptions); +} + +// ============================================================================= +// Process Management +// ============================================================================= + +/** + * Get process IDs using a specific port (cross-platform) + * @param {number} port - Port number to check + * @returns {number[]} - Array of PIDs using the port + */ +export function getProcessesOnPort(port) { + const pids = new Set(); + + if (isWindows) { + try { + const output = execCommand(`netstat -ano | findstr :${port}`); + if (output) { + const lines = output.split('\n'); + for (const line of lines) { + const match = line.match(/:\d+\s+.*?(\d+)\s*$/); + if (match) { + const pid = parseInt(match[1], 10); + if (pid > 0) pids.add(pid); + } + } + } + } catch { + // Ignore errors + } + } else { + try { + const output = execCommand(`lsof -ti:${port}`); + if (output) { + output.split('\n').forEach((pid) => { + const parsed = parseInt(pid.trim(), 10); + if (parsed > 0) pids.add(parsed); + }); + } + } catch { + // Ignore errors + } + } + + return Array.from(pids); +} + +/** + * Kill a process by PID (cross-platform) + * @param {number} pid - Process ID to kill + * @returns {boolean} - Whether the kill succeeded + */ +export function killProcess(pid) { + try { + if (isWindows) { + execCommand(`taskkill /F /PID ${pid}`); + } else { + process.kill(pid, 'SIGKILL'); + } + return true; + } catch { + return false; + } +} + +/** + * Check if a port is in use (without killing) + * @param {number} port - Port number to check + * @returns {boolean} - Whether the port is in use + */ +export function isPortInUse(port) { + const pids = getProcessesOnPort(port); + return pids.length > 0; +} + +/** + * Kill processes on a port and wait for it to be freed + * @param {number} port - Port number to free + * @returns {Promise} - Whether the port was freed + */ +export async function killPort(port) { + const pids = getProcessesOnPort(port); + + if (pids.length === 0) { + log(`✓ Port ${port} is available`, 'green'); + return true; + } + + log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); + + for (const pid of pids) { + killProcess(pid); + } + + // Wait for port to be freed (max 5 seconds) + for (let i = 0; i < 10; i++) { + await sleep(500); + const remainingPids = getProcessesOnPort(port); + if (remainingPids.length === 0) { + log(`✓ Port ${port} is now free`, 'green'); + return true; + } + } + + log(`Warning: Port ${port} may still be in use`, 'red'); + return false; +} + +/** + * Kill a process tree using tree-kill + * @param {number} pid - Root process ID + * @returns {Promise} + */ +export function killProcessTree(pid) { + return new Promise((resolve) => { + if (!pid) { + resolve(); + return; + } + treeKill(pid, 'SIGTERM', (err) => { + if (err) { + treeKill(pid, 'SIGKILL', () => resolve()); + } else { + resolve(); + } + }); + }); +} + +// ============================================================================= +// Utilities +// ============================================================================= + +/** + * Sleep for a given number of milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if the server health endpoint is responding + * @param {number} port - Server port (default 3008) + * @returns {Promise} - Whether the server is healthy + */ +export function checkHealth(port = 3008) { + return new Promise((resolve) => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * Prompt the user for input + * @param {string} question - Question to ask + * @returns {Promise} - User's answer + */ +export function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ============================================================================= +// Port Configuration Flow +// ============================================================================= + +/** + * Check ports and prompt user for resolution if in use + * @param {object} options - Configuration options + * @param {number} options.defaultWebPort - Default web port (3007) + * @param {number} options.defaultServerPort - Default server port (3008) + * @returns {Promise<{webPort: number, serverPort: number, corsOriginEnv: string}>} + */ +export async function resolvePortConfiguration({ + defaultWebPort = 3007, + defaultServerPort = 3008, +} = {}) { + log(`Checking for processes on ports ${defaultWebPort} and ${defaultServerPort}...`, 'yellow'); + + const webPortInUse = isPortInUse(defaultWebPort); + const serverPortInUse = isPortInUse(defaultServerPort); + + let webPort = defaultWebPort; + let serverPort = defaultServerPort; + + if (webPortInUse || serverPortInUse) { + console.log(''); + if (webPortInUse) { + const pids = getProcessesOnPort(defaultWebPort); + log(`⚠ Port ${defaultWebPort} is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + if (serverPortInUse) { + const pids = getProcessesOnPort(defaultServerPort); + log(`⚠ Port ${defaultServerPort} is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + console.log(''); + + while (true) { + const choice = await prompt( + 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' + ); + const lowerChoice = choice.toLowerCase(); + + if (lowerChoice === 'k' || lowerChoice === 'kill') { + if (webPortInUse) { + await killPort(defaultWebPort); + } else { + log(`✓ Port ${defaultWebPort} is available`, 'green'); + } + if (serverPortInUse) { + await killPort(defaultServerPort); + } else { + log(`✓ Port ${defaultServerPort} is available`, 'green'); + } + break; + } else if (lowerChoice === 'u' || lowerChoice === 'use') { + webPort = await promptForPort('web', defaultWebPort); + serverPort = await promptForPort('server', defaultServerPort, webPort); + log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); + break; + } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { + log('Cancelled.', 'yellow'); + process.exit(0); + } else { + log( + 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', + 'red' + ); + } + } + } else { + log(`✓ Port ${defaultWebPort} is available`, 'green'); + log(`✓ Port ${defaultServerPort} is available`, 'green'); + } + + // Build CORS origin env + const existing = (process.env.CORS_ORIGIN || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => o !== '*'); + const origins = new Set(existing); + origins.add(`http://localhost:${webPort}`); + origins.add(`http://127.0.0.1:${webPort}`); + const corsOriginEnv = Array.from(origins).join(','); + + console.log(''); + + return { webPort, serverPort, corsOriginEnv }; +} + +/** + * Prompt for a specific port with validation + * @param {string} name - Port name (web/server) + * @param {number} defaultPort - Default port value + * @param {number} excludePort - Port to exclude (optional) + * @returns {Promise} + */ +async function promptForPort(name, defaultPort, excludePort = null) { + while (true) { + const input = await prompt(`Enter ${name} port (default ${defaultPort}): `); + const parsed = input.trim() ? parseInt(input.trim(), 10) : defaultPort; + + if (isNaN(parsed) || parsed < 1024 || parsed > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (excludePort && parsed === excludePort) { + log(`${name} port cannot be the same as the other port.`, 'red'); + continue; + } + + if (isPortInUse(parsed)) { + const pids = getProcessesOnPort(parsed); + log(`Port ${parsed} is already in use by process(es): ${pids.join(', ')}`, 'red'); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + return parsed; + } +} + +// ============================================================================= +// UI Components +// ============================================================================= + +/** + * Print the application header banner + * @param {string} title - Header title + */ +export function printHeader(title) { + console.log('╔═══════════════════════════════════════════════════════╗'); + console.log(`║ ${title.padEnd(45)}║`); + console.log('╚═══════════════════════════════════════════════════════╝'); + console.log(''); +} + +/** + * Print the application mode menu + */ +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} - 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}`)); + }); + }); + } +} diff --git a/start.mjs b/start.mjs index 4d5153fd..0992ccfe 100755 --- a/start.mjs +++ b/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); }); From 6d41c7d0bc59f8b33bc4bd607b23a032cc3a38df Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:13:53 -0500 Subject: [PATCH 4/8] docs: update README for authentication setup and production launch - Revised instructions for starting Automaker, changing from `npm run dev` to `npm run start` for production mode. - Added a setup wizard for authentication on first run, with options for using Claude Code CLI or entering an API key. - Clarified development mode instructions, emphasizing the use of `npm run dev` for live reload and hot module replacement. --- README.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c8e1b84e..9ca0f368 100644 --- a/README.md +++ b/README.md @@ -120,29 +120,37 @@ npm install # 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly) npm run build:packages -# 4. Set up authentication (skip if using Claude Code CLI) -# If using Claude Code CLI: credentials are detected automatically -# If using API key directly, choose one method: - -# Option A: Environment variable -export ANTHROPIC_API_KEY="sk-ant-..." - -# Option B: Create .env file in project root -echo "ANTHROPIC_API_KEY=sk-ant-..." > .env - -# 5. Start Automaker (interactive launcher) -npm run dev +# 4. Start Automaker (production mode) +npm run start # Choose between: # 1. Web Application (browser at localhost:3007) # 2. Desktop Application (Electron - recommended) ``` -**Note:** The `npm run dev` command will: +**Note:** The `npm run start` command will: - Check for dependencies and install if needed -- Install Playwright browsers for E2E tests +- Build the application if needed - Kill any processes on ports 3007/3008 - Present an interactive menu to choose your run mode +- Run in production mode (no hot reload) + +**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to: + +- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically +- Enter an **API key** directly in the wizard + +If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually: + +```bash +# Option A: Environment variable +export ANTHROPIC_API_KEY="sk-ant-..." + +# Option B: Create .env file in project root +echo "ANTHROPIC_API_KEY=sk-ant-..." > .env +``` + +**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes. ## How to Run From d677910f40ccd0654cf7570271abd665f795d6f2 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:23:43 -0500 Subject: [PATCH 5/8] refactor: update permission handling and optimize performance measurement - Changed permissionMode settings in enhance and generate title routes to improve edit acceptance and default behavior. - Refactored performance measurement cleanup in the App component to only execute in development mode, preventing unnecessary operations in production. - Simplified the startServerAndWait function signature for better readability. --- .../src/routes/enhance-prompt/routes/enhance.ts | 4 +--- .../src/routes/features/routes/generate-title.ts | 4 +--- apps/ui/src/app.tsx | 14 ++++++++------ scripts/launcher-utils.mjs | 9 +-------- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 744a67b0..ad6e9602 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -164,9 +164,7 @@ export function createEnhanceHandler( systemPrompt, maxTurns: 1, allowedTools: [], - // AUTONOMOUS MODE: Always bypass permissions - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, + permissionMode: 'acceptEdits', }, }); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 49c59801..2602de03 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -96,9 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P systemPrompt: SYSTEM_PROMPT, maxTurns: 1, allowedTools: [], - // AUTONOMOUS MODE: Always bypass permissions - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, + permissionMode: 'default', }, }); diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index c14ab6d0..a45073c6 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -18,12 +18,14 @@ export default function App() { // Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode // React's internal scheduler creates performance marks/measures that accumulate without cleanup useEffect(() => { - const clearPerfEntries = () => { - performance.clearMarks(); - performance.clearMeasures(); - }; - const interval = setInterval(clearPerfEntries, 5000); - return () => clearInterval(interval); + if (import.meta.env.DEV) { + const clearPerfEntries = () => { + performance.clearMarks(); + performance.clearMeasures(); + }; + const interval = setInterval(clearPerfEntries, 5000); + return () => clearInterval(interval); + } }, []); // Run settings migration on startup (localStorage -> file storage) diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index af68e452..4e09b54a 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -558,14 +558,7 @@ export function setupSignalHandlers(cleanup) { * @param {object} options - Configuration options * @returns {Promise} - Server process */ -export async function startServerAndWait({ - serverPort, - corsOriginEnv, - npmArgs, - cwd, - fs, - baseDir, -}) { +export async function startServerAndWait({ serverPort, corsOriginEnv, npmArgs, cwd, fs, baseDir }) { log(`Starting backend server on port ${serverPort}...`, 'blue'); // Create logs directory From afb0937cb3031293c18a604a8a34e47d01e7aabc Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:26:26 -0500 Subject: [PATCH 6/8] refactor: update permissionMode to bypassPermissions in SDK options and tests - Changed permissionMode from 'default' to 'bypassPermissions' in sdk-options and claude-provider unit tests. - Added allowDangerouslySkipPermissions flag in claude-provider test to enhance permission handling. --- apps/server/tests/unit/lib/sdk-options.test.ts | 2 +- apps/server/tests/unit/providers/claude-provider.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 3faea516..d55210b0 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -234,7 +234,7 @@ describe('sdk-options.ts', () => { expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(MAX_TURNS.maximum); expect(options.allowedTools).toEqual([...TOOL_PRESETS.specGeneration]); - expect(options.permissionMode).toBe('default'); + expect(options.permissionMode).toBe('bypassPermissions'); }); it('should include system prompt when provided', async () => { diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 3dbd9982..96110295 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -73,7 +73,8 @@ describe('claude-provider.ts', () => { maxTurns: 10, cwd: '/test/dir', allowedTools: ['Read', 'Write'], - permissionMode: 'default', + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }), }); }); From 586aabe11f62424c0f7bcc67faa5dbf7b8609742 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:36:22 -0500 Subject: [PATCH 7/8] chore: update .gitignore and improve cleanup handling in scripts - Added .claude/hans/ to .gitignore to prevent tracking of specific directory. - Updated cleanup calls in dev.mjs and start.mjs to use await for proper asynchronous handling. - Enhanced error handling during cleanup in case of failures. - Improved server failure handling in startServerAndWait function to ensure proper termination of failed processes. --- .gitignore | 1 + dev.mjs | 10 +++++++--- package-lock.json | 4 ++-- scripts/launcher-utils.mjs | 19 +++++++++++++++++++ start.mjs | 10 +++++++--- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 48470efe..7d02e8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ blob-report/ docker-compose.override.yml .claude/docker-compose.override.yml +.claude/hans/ pnpm-lock.yaml yarn.lock \ No newline at end of file diff --git a/dev.mjs b/dev.mjs index f2ad01cc..48ef9e67 100644 --- a/dev.mjs +++ b/dev.mjs @@ -117,7 +117,7 @@ async function main() { }); if (!processes.server) { - cleanup(); + await cleanup(); process.exit(1); } @@ -175,9 +175,13 @@ async function main() { } // Run main function -main().catch((err) => { +main().catch(async (err) => { console.error(err); const cleanup = createCleanupHandler(processes); - cleanup(); + try { + await cleanup(); + } catch (cleanupErr) { + console.error('Cleanup error:', cleanupErr); + } process.exit(1); }); diff --git a/package-lock.json b/package-lock.json index 48840b71..98ca8545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.7.1", + "version": "0.7.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -78,7 +78,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.7.1", + "version": "0.7.3", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index 4e09b54a..2a1d4e71 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -610,6 +610,25 @@ export async function startServerAndWait({ serverPort, corsOriginEnv, npmArgs, c if (!serverReady) { log('Error: Server failed to start', 'red'); console.log('Check logs/server.log for details'); + + // Clean up the spawned server process that failed health check + if (serverProcess && !serverProcess.killed && serverProcess.pid) { + log('Terminating failed server process...', 'yellow'); + try { + await killProcessTree(serverProcess.pid); + } catch (killErr) { + // Fallback: try direct kill if tree-kill fails + try { + serverProcess.kill('SIGKILL'); + } catch { + // Process may have already exited + } + } + } + + // Close the log stream + logStream.end(); + return null; } diff --git a/start.mjs b/start.mjs index 0992ccfe..54213e4f 100755 --- a/start.mjs +++ b/start.mjs @@ -161,7 +161,7 @@ async function main() { }); if (!processes.server) { - cleanup(); + await cleanup(); process.exit(1); } @@ -256,9 +256,13 @@ async function main() { } // Run main function -main().catch((err) => { +main().catch(async (err) => { console.error(err); const cleanup = createCleanupHandler(processes); - cleanup(); + try { + await cleanup(); + } catch (cleanupErr) { + console.error('Cleanup error:', cleanupErr); + } process.exit(1); }); From 22aa24ae0432fc16a077d35f808b8b1a160b3086 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:53:44 -0500 Subject: [PATCH 8/8] feat: add Docker container launch option and update process handling - Introduced a new option to launch the application in a Docker container (Isolated Mode) from the main menu. - Added checks for the ANTHROPIC_API_KEY environment variable to ensure proper API functionality. - Updated process management to include Docker, allowing for better cleanup and handling of spawned processes. - Enhanced user prompts and logging for improved clarity during the launch process. --- .dockerignore | 1 + dev.mjs | 39 ++++++++++++++++- scripts/launcher-utils.mjs | 7 ++- start.mjs | 87 ++++++++++++++++++++++---------------- 4 files changed, 94 insertions(+), 40 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..40b878db --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/dev.mjs b/dev.mjs index 48ef9e67..7236d14f 100644 --- a/dev.mjs +++ b/dev.mjs @@ -42,6 +42,7 @@ const processes = { server: null, web: null, electron: null, + docker: null, }; /** @@ -96,7 +97,7 @@ async function main() { // Prompt for choice while (true) { - const choice = await prompt('Enter your choice (1 or 2): '); + const choice = await prompt('Enter your choice (1, 2, or 3): '); if (choice === '1') { console.log(''); @@ -167,9 +168,43 @@ async function main() { processes.electron.on('close', resolve); }); + break; + } else if (choice === '3') { + console.log(''); + log('Launching Docker Container (Isolated Mode)...', 'blue'); + log('Building and starting Docker containers...', 'yellow'); + console.log(''); + + // Check if ANTHROPIC_API_KEY is set + if (!process.env.ANTHROPIC_API_KEY) { + log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); + log('The server will require an API key to function.', 'yellow'); + log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); + console.log(''); + } + + // Build and start containers with docker-compose + processes.docker = crossSpawn('docker', ['compose', 'up', '--build'], { + stdio: 'inherit', + cwd: __dirname, + env: { + ...process.env, + }, + }); + + log('Docker containers starting...', 'blue'); + log('UI will be available at: http://localhost:3007', 'green'); + log('API will be available at: http://localhost:3008', 'green'); + console.log(''); + log('Press Ctrl+C to stop the containers.', 'yellow'); + + await new Promise((resolve) => { + processes.docker.on('close', resolve); + }); + break; } else { - log('Invalid choice. Please enter 1 or 2.', 'red'); + log('Invalid choice. Please enter 1, 2, or 3.', 'red'); } } } diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index 2a1d4e71..215c0dc2 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -496,6 +496,7 @@ export function printModeMenu() { console.log('═══════════════════════════════════════════════════════'); console.log(' 1) Web Application (Browser)'); console.log(' 2) Desktop Application (Electron)'); + console.log(' 3) Docker Container (Isolated)'); console.log('═══════════════════════════════════════════════════════'); console.log(''); } @@ -506,7 +507,7 @@ export function printModeMenu() { /** * Create a cleanup handler for spawned processes - * @param {object} processes - Object with process references {server, web, electron} + * @param {object} processes - Object with process references {server, web, electron, docker} * @returns {Function} - Cleanup function */ export function createCleanupHandler(processes) { @@ -527,6 +528,10 @@ export function createCleanupHandler(processes) { killPromises.push(killProcessTree(processes.electron.pid)); } + if (processes.docker && !processes.docker.killed && processes.docker.pid) { + killPromises.push(killProcessTree(processes.docker.pid)); + } + await Promise.all(killPromises); }; } diff --git a/start.mjs b/start.mjs index 54213e4f..22e12428 100755 --- a/start.mjs +++ b/start.mjs @@ -18,6 +18,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; import { createRestrictedFs, log, @@ -36,6 +37,9 @@ import { sleep, } 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); @@ -47,26 +51,11 @@ const processes = { server: null, web: null, electron: null, + docker: null, }; /** - * Check if production builds exist - * @returns {{server: boolean, ui: boolean, electron: boolean}} - */ -function checkBuilds() { - const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); - const uiDist = path.join(__dirname, 'apps', 'ui', 'dist'); - const electronDist = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); - - return { - server: fs.existsSync(serverDist), - ui: fs.existsSync(uiDist), - electron: fs.existsSync(electronDist), - }; -} - -/** - * Build all production artifacts if needed + * Build all production artifacts */ async function ensureProductionBuilds() { // Always build shared packages first to ensure they're up to date @@ -93,25 +82,15 @@ async function ensureProductionBuilds() { process.exit(1); } - // Check if UI/Electron builds exist (these are slower, so only build if missing) - const builds = checkBuilds(); - - if (!builds.ui || !builds.electron) { - log('UI/Electron builds not found. Building...', 'yellow'); - console.log(''); - - try { - log('Building UI...', 'blue'); - await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname); - log('✓ Build complete!', 'green'); - console.log(''); - } catch (error) { - log(`Build failed: ${error.message}`, 'red'); - process.exit(1); - } - } else { - log('✓ UI builds found', 'green'); + // Always rebuild UI to ensure it's in sync with latest code + log('Building UI...', 'blue'); + try { + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname); + log('✓ UI built', 'green'); console.log(''); + } catch (error) { + log(`Failed to build UI: ${error.message}`, 'red'); + process.exit(1); } } @@ -142,7 +121,7 @@ async function main() { // Prompt for choice while (true) { - const choice = await prompt('Enter your choice (1 or 2): '); + const choice = await prompt('Enter your choice (1, 2, or 3): '); if (choice === '1') { console.log(''); @@ -248,9 +227,43 @@ async function main() { }); }); + break; + } else if (choice === '3') { + console.log(''); + log('Launching Docker Container (Isolated Mode)...', 'blue'); + log('Building and starting Docker containers...', 'yellow'); + console.log(''); + + // Check if ANTHROPIC_API_KEY is set + if (!process.env.ANTHROPIC_API_KEY) { + log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); + log('The server will require an API key to function.', 'yellow'); + log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); + console.log(''); + } + + // Build and start containers with docker-compose + processes.docker = crossSpawn('docker', ['compose', 'up', '--build'], { + stdio: 'inherit', + cwd: __dirname, + env: { + ...process.env, + }, + }); + + log('Docker containers starting...', 'blue'); + log('UI will be available at: http://localhost:3007', 'green'); + log('API will be available at: http://localhost:3008', 'green'); + console.log(''); + log('Press Ctrl+C to stop the containers.', 'yellow'); + + await new Promise((resolve) => { + processes.docker.on('close', resolve); + }); + break; } else { - log('Invalid choice. Please enter 1 or 2.', 'red'); + log('Invalid choice. Please enter 1, 2, or 3.', 'red'); } } }