From 66557b20930b31974db62ec1f3e6d5395e96c49b Mon Sep 17 00:00:00 2001 From: webdevcody Date: Tue, 6 Jan 2026 17:26:25 -0500 Subject: [PATCH] feat: add support for external server mode with Docker integration - Introduced a new `docker-compose.dev-server.yml` for running the backend API in a container, enabling local Electron to connect to it. - Updated `dev.mjs` to include a new option for launching the Docker server container. - Enhanced the UI application to support external server mode, allowing session-based authentication and adjusting routing logic accordingly. - Added utility functions to check and cache the external server mode status for improved performance. - Updated various components to handle authentication and routing based on the server mode. --- apps/ui/src/lib/http-api-client.ts | 33 ++++++++ apps/ui/src/main.ts | 54 ++++++++++--- apps/ui/src/preload.ts | 3 + apps/ui/src/routes/__root.tsx | 31 +++++--- dev.mjs | 9 ++- docker-compose.dev-server.yml | 103 +++++++++++++++++++++++++ scripts/launcher-utils.mjs | 118 +++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+), 21 deletions(-) create mode 100644 docker-compose.dev-server.yml diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 2fad2389..335053e4 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -131,6 +131,39 @@ export const isElectronMode = (): boolean => { return api?.isElectron === true || !!api?.getApiKey; }; +// Cached external server mode flag +let cachedExternalServerMode: boolean | null = null; + +/** + * Check if running in external server mode (Docker API) + * In this mode, Electron uses session-based auth like web mode + */ +export const checkExternalServerMode = async (): Promise => { + if (cachedExternalServerMode !== null) { + return cachedExternalServerMode; + } + + if (typeof window !== 'undefined') { + const api = window.electronAPI as any; + if (api?.isExternalServerMode) { + try { + cachedExternalServerMode = await api.isExternalServerMode(); + return cachedExternalServerMode; + } catch (error) { + logger.warn('Failed to check external server mode:', error); + } + } + } + + cachedExternalServerMode = false; + return false; +}; + +/** + * Get cached external server mode (synchronous, returns null if not yet checked) + */ +export const isExternalServerMode = (): boolean | null => cachedExternalServerMode; + /** * Initialize API key and server URL for Electron mode authentication. * In web mode, authentication uses HTTP-only cookies instead. diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 0d86830a..8930d664 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -132,6 +132,9 @@ let saveWindowBoundsTimeout: ReturnType | null = null; // API key for CSRF protection let apiKey: string | null = null; +// Track if we're using an external server (Docker API mode) +let isExternalServerMode = false; + /** * Get the relative path to API key file within userData */ @@ -688,14 +691,35 @@ app.whenReady().then(async () => { } } - // Generate or load API key for CSRF protection (before starting server) - ensureApiKey(); - try { - // Find available ports (prevents conflicts with other apps using same ports) - serverPort = await findAvailablePort(DEFAULT_SERVER_PORT); - if (serverPort !== DEFAULT_SERVER_PORT) { - logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort); + // Check if we should skip the embedded server (for Docker API mode) + const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true'; + isExternalServerMode = skipEmbeddedServer; + + if (skipEmbeddedServer) { + // Use the default server port (Docker container runs on 3008) + serverPort = DEFAULT_SERVER_PORT; + logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', serverPort); + + // Wait for external server to be ready + logger.info('Waiting for external server...'); + await waitForServer(60); // Give Docker container more time to start + logger.info('External server is ready'); + + // In external server mode, we don't set an API key here. + // The renderer will detect external server mode and use session-based + // auth like web mode, redirecting to /login where the user enters + // the API key from the Docker container logs. + logger.info('External server mode: using session-based authentication'); + } else { + // Generate or load API key for CSRF protection (before starting server) + ensureApiKey(); + + // Find available ports (prevents conflicts with other apps using same ports) + serverPort = await findAvailablePort(DEFAULT_SERVER_PORT); + if (serverPort !== DEFAULT_SERVER_PORT) { + logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort); + } } staticPort = await findAvailablePort(DEFAULT_STATIC_PORT); @@ -708,8 +732,10 @@ app.whenReady().then(async () => { await startStaticServer(); } - // Start backend server - await startServer(); + // Start backend server (unless using external server) + if (!skipEmbeddedServer) { + await startServer(); + } // Create window createWindow(); @@ -909,10 +935,20 @@ ipcMain.handle('server:getUrl', async () => { }); // Get API key for authentication +// Returns null in external server mode to trigger session-based auth ipcMain.handle('auth:getApiKey', () => { + if (isExternalServerMode) { + return null; + } return apiKey; }); +// Check if running in external server mode (Docker API) +// Used by renderer to determine auth flow +ipcMain.handle('auth:isExternalServerMode', () => { + return isExternalServerMode; +}); + // Window management - update minimum width based on sidebar state // Now uses a fixed small minimum since horizontal scrolling handles overflow ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index d6598e2a..3fa70c00 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Get API key for authentication getApiKey: (): Promise => ipcRenderer.invoke('auth:getApiKey'), + // Check if running in external server mode (Docker API) + isExternalServerMode: (): Promise => ipcRenderer.invoke('auth:isExternalServerMode'), + // Native dialogs - better UX than prompt() openDirectory: (): Promise => ipcRenderer.invoke('dialog:openDirectory'), diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 34dbd00e..ce21a07d 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -18,6 +18,8 @@ import { verifySession, checkSandboxEnvironment, getServerUrlSync, + checkExternalServerMode, + isExternalServerMode, } from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; @@ -188,13 +190,16 @@ function RootLayoutContent() { // Initialize API key for Electron mode await initApiKey(); - // In Electron mode, we're always authenticated via header - if (isElectronMode()) { + // Check if running in external server mode (Docker API) + const externalMode = await checkExternalServerMode(); + + // In Electron mode (but NOT external server mode), we're always authenticated via header + if (isElectronMode() && !externalMode) { useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true }); return; } - // In web mode, verify the session cookie is still valid + // In web mode OR external server mode, verify the session cookie is still valid // by making a request to an authenticated endpoint const isValid = await verifySession(); @@ -235,17 +240,20 @@ function RootLayoutContent() { }; }, []); - // Routing rules (web mode): + // Routing rules (web mode and external server mode): // - If not authenticated: force /login (even /setup is protected) // - If authenticated but setup incomplete: force /setup useEffect(() => { if (!setupHydrated) return; + // Check if we need session-based auth (web mode OR external server mode) + const needsSessionAuth = !isElectronMode() || isExternalServerMode() === true; + // Wait for auth check to complete before enforcing any redirects - if (!isElectronMode() && !authChecked) return; + if (needsSessionAuth && !authChecked) return; // Unauthenticated -> force /login - if (!isElectronMode() && !isAuthenticated) { + if (needsSessionAuth && !isAuthenticated) { if (location.pathname !== '/login') { navigate({ to: '/login' }); } @@ -351,8 +359,11 @@ function RootLayoutContent() { ); } - // Wait for auth check before rendering protected routes (web mode only) - if (!isElectronMode() && !authChecked) { + // Check if we need session-based auth (web mode OR external server mode) + const needsSessionAuth = !isElectronMode() || isExternalServerMode() === true; + + // Wait for auth check before rendering protected routes (web mode and external server mode) + if (needsSessionAuth && !authChecked) { return (
@@ -360,9 +371,9 @@ function RootLayoutContent() { ); } - // Redirect to login if not authenticated (web mode) + // Redirect to login if not authenticated (web mode and external server mode) // Show loading state while navigation to login is in progress - if (!isElectronMode() && !isAuthenticated) { + if (needsSessionAuth && !isAuthenticated) { return (
diff --git a/dev.mjs b/dev.mjs index 58b81d7a..289090c6 100644 --- a/dev.mjs +++ b/dev.mjs @@ -27,6 +27,7 @@ import { ensureDependencies, prompt, launchDockerDevContainers, + launchDockerDevServerContainer, } from './scripts/launcher-utils.mjs'; const __filename = fileURLToPath(import.meta.url); @@ -96,7 +97,7 @@ async function main() { // Prompt for choice while (true) { - const choice = await prompt('Enter your choice (1, 2, or 3): '); + const choice = await prompt('Enter your choice (1, 2, 3, or 4): '); if (choice === '1') { console.log(''); @@ -172,8 +173,12 @@ async function main() { console.log(''); await launchDockerDevContainers({ baseDir: __dirname, processes }); break; + } else if (choice === '4') { + console.log(''); + await launchDockerDevServerContainer({ baseDir: __dirname, processes }); + break; } else { - log('Invalid choice. Please enter 1, 2, or 3.', 'red'); + log('Invalid choice. Please enter 1, 2, 3, or 4.', 'red'); } } } diff --git a/docker-compose.dev-server.yml b/docker-compose.dev-server.yml new file mode 100644 index 00000000..7765dcb5 --- /dev/null +++ b/docker-compose.dev-server.yml @@ -0,0 +1,103 @@ +# Automaker Docker Compose - Server Only (Development Mode) +# Runs only the backend API in a container for use with local Electron. +# +# Usage: +# docker compose -f docker-compose.dev-server.yml up +# Then run Electron locally which connects to http://localhost:3008 +# +# This mode: +# - Runs only the backend server in a container +# - Mounts source code as volumes (live reload) +# - Server runs with tsx watch for TypeScript changes +# - Electron runs locally on host machine + +services: + # Development server (backend API only) + server: + build: + context: . + dockerfile: Dockerfile.dev + container_name: automaker-dev-server-only + restart: unless-stopped + ports: + - '3008:3008' + environment: + # Required + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + + # Optional - Claude CLI OAuth credentials + - CLAUDE_OAUTH_CREDENTIALS=${CLAUDE_OAUTH_CREDENTIALS:-} + + # Optional - Cursor CLI OAuth token + - CURSOR_AUTH_TOKEN=${CURSOR_AUTH_TOKEN:-} + + # Optional - authentication + - AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-} + + # Development settings + - NODE_ENV=development + - PORT=3008 + - CORS_ORIGIN=http://localhost:3007 + + # Optional - restrict to specific directory within container + - ALLOWED_ROOT_DIRECTORY=${ALLOWED_ROOT_DIRECTORY:-/projects} + - DATA_DIR=/data + + # Internal - indicates containerized environment + - IS_CONTAINERIZED=true + volumes: + # Mount source code for live reload + - .:/app:cached + + # Use named volume for node_modules to avoid platform conflicts + # This ensures native modules are built for the container's architecture + - automaker-dev-node-modules:/app/node_modules + + # Persist data across restarts + - automaker-data:/data + + # Persist CLI configurations + - automaker-claude-config:/home/automaker/.claude + - automaker-cursor-config:/home/automaker/.cursor + + # Note: Workspace mount (/projects) comes from docker-compose.override.yml + + # Install deps, build packages, then start server in watch mode + # Note: We override the entrypoint to handle permissions properly + entrypoint: /bin/sh + command: + - -c + - | + # Fix permissions on node_modules (created as root by Docker volume) + echo 'Fixing node_modules permissions...' + chown -R automaker:automaker /app/node_modules 2>/dev/null || true + + # Run the rest as automaker user + exec gosu automaker sh -c " + echo 'Installing dependencies...' && + npm install && + echo 'Building shared packages...' && + npm run build:packages && + echo 'Starting server in development mode...' && + npm run _dev:server + " + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3008/api/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s + +volumes: + automaker-dev-node-modules: + name: automaker-dev-node-modules + # Named volume for container-specific node_modules + + automaker-data: + name: automaker-data + + automaker-claude-config: + name: automaker-claude-config + + automaker-cursor-config: + name: automaker-cursor-config diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index cdadd56e..69aa036f 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -500,6 +500,7 @@ export function printModeMenu({ isDev = false } = {}) { console.log(' 2) Desktop Application (Electron)'); if (isDev) { console.log(' 3) Docker Container (Dev with Live Reload)'); + console.log(' 4) Electron + Docker API (Local Electron, Container API)'); } else { console.log(' 3) Docker Container (Isolated)'); } @@ -883,6 +884,123 @@ export async function launchDockerDevContainers({ baseDir, processes }) { }); } +/** + * Launch only the Docker server container for use with local Electron + * Uses docker-compose.dev-server.yml which only runs the backend API + * Also includes docker-compose.override.yml if it exists (for workspace mounts) + * Automatically launches Electron once the server is healthy. + * @param {object} options - Configuration options + * @param {string} options.baseDir - Base directory containing docker-compose.dev-server.yml + * @param {object} options.processes - Processes object to track docker process + * @returns {Promise} + */ +export async function launchDockerDevServerContainer({ baseDir, processes }) { + log('Launching Docker Server Container + Local Electron...', 'blue'); + 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(''); + } + + log('Starting server container...', 'yellow'); + log('Source code is volume mounted for live reload', 'yellow'); + log('Running npm install inside container (this may take a moment on first run)...', 'yellow'); + console.log(''); + + // Build compose file arguments + // Start with dev-server compose file, then add override if it exists + const composeArgs = ['compose', '-f', 'docker-compose.dev-server.yml']; + + // Check if docker-compose.override.yml exists and include it for workspace mounts + const overridePath = path.join(baseDir, 'docker-compose.override.yml'); + if (fsNative.existsSync(overridePath)) { + composeArgs.push('-f', 'docker-compose.override.yml'); + log('Using docker-compose.override.yml for workspace mount', 'yellow'); + } + + composeArgs.push('up', '--build'); + + // Use docker-compose.dev-server.yml for server-only development + // Run with piped stdio so we can still see output but also run Electron + processes.docker = crossSpawn('docker', composeArgs, { + stdio: 'inherit', + cwd: baseDir, + env: { + ...process.env, + }, + }); + + log('Server container starting...', 'blue'); + log('API will be available at: http://localhost:3008', 'green'); + console.log(''); + + // Wait for the server to become healthy + log('Waiting for server to be ready...', 'yellow'); + const serverPort = 3008; + const maxRetries = 120; // 2 minutes (first run may need npm install + build) + let serverReady = false; + + for (let i = 0; i < maxRetries; i++) { + if (await checkHealth(serverPort)) { + serverReady = true; + break; + } + await sleep(1000); + // Show progress dots every 5 seconds + if (i > 0 && i % 5 === 0) { + process.stdout.write('.'); + } + } + + if (!serverReady) { + console.log(''); + log('Error: Server container failed to become healthy', 'red'); + log('Check the Docker logs above for errors', 'red'); + return; + } + + console.log(''); + log('Server is ready! Launching Electron...', 'green'); + console.log(''); + + // Build shared packages before launching Electron + log('Building shared packages...', 'blue'); + try { + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, baseDir); + } catch (error) { + log('Failed to build packages: ' + error.message, 'red'); + return; + } + + // Launch Electron with SKIP_EMBEDDED_SERVER=true + // This tells Electron to connect to the external Docker server instead of starting its own + processes.electron = crossSpawn('npm', ['run', '_dev:electron'], { + stdio: 'inherit', + cwd: baseDir, + env: { + ...process.env, + SKIP_EMBEDDED_SERVER: 'true', + PORT: '3008', + VITE_SERVER_URL: 'http://localhost:3008', + }, + }); + + log('Electron launched with SKIP_EMBEDDED_SERVER=true', 'green'); + log('Changes to server source files will automatically reload.', 'yellow'); + log('Press Ctrl+C to stop both Electron and the container.', 'yellow'); + console.log(''); + + // Wait for either process to exit + await Promise.race([ + new Promise((resolve) => processes.docker.on('close', resolve)), + new Promise((resolve) => processes.electron.on('close', resolve)), + ]); +} + /** * Launch Docker containers with docker-compose (production mode) * Uses git commit SHA to determine if rebuild is needed