Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/ui/src/lib/http-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
if (cachedExternalServerMode !== null) {
return cachedExternalServerMode;
}

if (typeof window !== 'undefined') {
const api = window.electronAPI as any;
if (api?.isExternalServerMode) {
Comment on lines +147 to +148
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of as any to access isExternalServerMode indicates that the type definition for window.electronAPI (likely ElectronAPI) is missing this new property. While this works, it bypasses TypeScript's type safety. To improve maintainability and type safety, the ElectronAPI interface should be updated to include the optional isExternalServerMode method. This would remove the need for the type assertion.

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.
Expand Down
54 changes: 45 additions & 9 deletions apps/ui/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | 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
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get API key for authentication
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),

// Check if running in external server mode (Docker API)
isExternalServerMode: (): Promise<boolean> => ipcRenderer.invoke('auth:isExternalServerMode'),

// Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'),
Expand Down
31 changes: 21 additions & 10 deletions apps/ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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' });
}
Expand Down Expand Up @@ -351,18 +359,21 @@ 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;
Comment on lines +362 to +363
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic to define needsSessionAuth is duplicated from line 250. To improve maintainability and avoid potential inconsistencies, this constant should be defined only once at the top of the RootLayoutContent component and then reused in both the useEffect hook and this rendering logic.


// Wait for auth check before rendering protected routes (web mode and external server mode)
if (needsSessionAuth && !authChecked) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Loading..." />
</main>
);
}

// 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 (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Redirecting to login..." />
Expand Down
9 changes: 7 additions & 2 deletions dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ensureDependencies,
prompt,
launchDockerDevContainers,
launchDockerDevServerContainer,
} from './scripts/launcher-utils.mjs';

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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');
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions docker-compose.dev-server.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading