Merge pull request #353 from AutoMaker-Org/fix-memory-leak

Fix memory leak
This commit is contained in:
Web Dev Cody
2026-01-03 23:57:11 -05:00
committed by GitHub
30 changed files with 1294 additions and 1030 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules/

1
.gitignore vendored
View File

@@ -81,6 +81,7 @@ blob-report/
docker-compose.override.yml
.claude/docker-compose.override.yml
.claude/hans/
pnpm-lock.yaml
yarn.lock

View File

@@ -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

View File

@@ -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

17
TODO.md Normal file
View File

@@ -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.

View File

@@ -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<Options> {
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<string, McpServerConfig>;
/** 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,

View File

@@ -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.

View File

@@ -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

View File

@@ -96,7 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
permissionMode: 'default',
},
});

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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
});
});
});

View File

@@ -73,7 +73,8 @@ describe('claude-provider.ts', () => {
maxTurns: 10,
cwd: '/test/dir',
allowedTools: ['Read', 'Write'],
permissionMode: 'default',
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
}),
});
});

View File

@@ -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,19 @@ 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(() => {
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)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {

View File

@@ -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';

View File

@@ -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 (
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
<div className="space-y-4">
<div className="flex items-start gap-3">
<Switch
id="mcp-auto-approve"
checked={mcpAutoApproveTools}
onCheckedChange={async (checked) => {
onAutoApproveChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-auto-approve-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
Auto-approve MCP tool calls
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use MCP tools without permission prompts.
</p>
{mcpAutoApproveTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Bypasses normal permission checks
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Switch
id="mcp-unrestricted"
checked={mcpUnrestrictedTools}
onCheckedChange={async (checked) => {
onUnrestrictedChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-unrestricted-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
Unrestricted tool access
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use any tool, not just the default set.
</p>
{mcpUnrestrictedTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Agent has full tool access including file writes and bash
</p>
)}
</div>
</div>
{hasAnyEnabled && (
<div
className={cn(
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
'text-xs text-amber-700 dark:text-amber-400'
)}
>
<p className="font-medium mb-1">Security Note</p>
<p>
These settings reduce security restrictions for MCP tool usage. Only enable if you
trust all configured MCP servers.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -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,

View File

@@ -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 && (
<MCPPermissionSettings
mcpAutoApproveTools={mcpAutoApproveTools}
mcpUnrestrictedTools={mcpUnrestrictedTools}
onAutoApproveChange={setMcpAutoApproveTools}
onUnrestrictedChange={setMcpUnrestrictedTools}
/>
)}
{showToolsWarning && <MCPToolsWarning totalTools={totalToolsCount} />}
<div className="p-6">

View File

@@ -230,8 +230,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
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<boolean> {
}
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;

View File

@@ -1438,8 +1438,6 @@ export class HttpApiClient implements ElectronAPI {
headers?: Record<string, string>;
enabled?: boolean;
}>;
mcpAutoApproveTools?: boolean;
mcpUnrestrictedTools?: boolean;
};
error?: string;
}> => this.get('/api/settings/global'),

View File

@@ -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<void>;
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
@@ -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<AppState & AppActions>()(
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<AppState & AppActions>()(
skipSandboxWarning: state.skipSandboxWarning,
// MCP settings
mcpServers: state.mcpServers,
mcpAutoApproveTools: state.mcpAutoApproveTools,
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
// Prompt customization
promptCustomization: state.promptCustomization,
// Profiles and sessions

222
dev.mjs Normal file
View File

@@ -0,0 +1,222 @@
#!/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,
docker: 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, 2, or 3): ');
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) {
await 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 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, 2, or 3.', 'red');
}
}
}
// Run main function
main().catch(async (err) => {
console.error(err);
const cleanup = createCleanupHandler(processes);
try {
await cleanup();
} catch (cleanupErr) {
console.error('Cleanup error:', cleanupErr);
}
process.exit(1);
});

649
init.mjs
View File

@@ -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);
});

View File

@@ -71,8 +71,6 @@ export interface ExecuteOptions {
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, McpServerConfig>;
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

View File

@@ -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) */

4
package-lock.json generated
View File

@@ -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": {

View File

@@ -12,7 +12,8 @@
"scripts": {
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs",
"fix:lockfile": "node scripts/fix-lockfile-urls.mjs",
"dev": "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",
"_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",

664
scripts/launcher-utils.mjs Normal file
View File

@@ -0,0 +1,664 @@
/**
* Shared utilities for Automaker launcher scripts (dev.mjs and start.mjs)
*
* This module contains cross-platform utilities for:
* - Process management (ports, killing processes)
* - Terminal output (colors, logging)
* - npm/npx command execution
* - User prompts
* - Health checks
*
* SECURITY NOTE: Uses a restricted fs wrapper that only allows
* operations within a specified base directory.
*/
import { execSync } from 'child_process';
import fsNative from 'fs';
import http from 'http';
import path from 'path';
import readline from 'readline';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const treeKill = require('tree-kill');
const crossSpawn = require('cross-spawn');
// =============================================================================
// Terminal Colors
// =============================================================================
export const colors = {
green: '\x1b[0;32m',
blue: '\x1b[0;34m',
yellow: '\x1b[1;33m',
red: '\x1b[0;31m',
reset: '\x1b[0m',
};
export const isWindows = process.platform === 'win32';
// =============================================================================
// Restricted fs wrapper - only allows operations within a base directory
// =============================================================================
/**
* Create a restricted fs wrapper for a given base directory
* @param {string} baseDir - The base directory to restrict operations to
* @param {string} scriptName - Name of the calling script for error messages
* @returns {object} - Restricted fs operations
*/
export function createRestrictedFs(baseDir, scriptName = 'launcher') {
const normalizedBase = path.resolve(baseDir);
function validatePath(targetPath) {
const resolved = path.resolve(baseDir, targetPath);
if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
throw new Error(
`[${scriptName}] Security: Path access denied outside script directory: ${targetPath}`
);
}
return resolved;
}
return {
existsSync(targetPath) {
const validated = validatePath(targetPath);
return fsNative.existsSync(validated);
},
mkdirSync(targetPath, options) {
const validated = validatePath(targetPath);
return fsNative.mkdirSync(validated, options);
},
createWriteStream(targetPath) {
const validated = validatePath(targetPath);
return fsNative.createWriteStream(validated);
},
};
}
// =============================================================================
// Logging
// =============================================================================
/**
* Print colored output
* @param {string} message - Message to print
* @param {string} color - Color name (green, blue, yellow, red, reset)
*/
export function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
// =============================================================================
// Command Execution
// =============================================================================
/**
* Execute a command synchronously and return stdout
* @param {string} command - Command to execute
* @param {object} options - execSync options
* @returns {string|null} - Command output or null on error
*/
export function execCommand(command, options = {}) {
try {
return execSync(command, {
encoding: 'utf8',
stdio: 'pipe',
...options,
}).trim();
} catch {
return null;
}
}
/**
* Run npm command using cross-spawn for Windows compatibility
* @param {string[]} args - npm command arguments
* @param {object} options - spawn options
* @param {string} cwd - Working directory
* @returns {ChildProcess} - Spawned process
*/
export function runNpm(args, options = {}, cwd = process.cwd()) {
const { env, ...restOptions } = options;
const spawnOptions = {
stdio: 'inherit',
cwd,
...restOptions,
env: {
...process.env,
...(env || {}),
},
};
return crossSpawn('npm', args, spawnOptions);
}
/**
* Run an npm command and wait for completion
* @param {string[]} args - npm command arguments
* @param {object} options - spawn options
* @param {string} cwd - Working directory
* @returns {Promise<void>}
*/
export function runNpmAndWait(args, options = {}, cwd = process.cwd()) {
const child = runNpm(args, options, cwd);
return new Promise((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`));
});
child.on('error', (err) => reject(err));
});
}
/**
* Run npx command using cross-spawn for Windows compatibility
* @param {string[]} args - npx command arguments
* @param {object} options - spawn options
* @param {string} cwd - Working directory
* @returns {ChildProcess} - Spawned process
*/
export function runNpx(args, options = {}, cwd = process.cwd()) {
const { env, ...restOptions } = options;
const spawnOptions = {
stdio: 'inherit',
cwd,
...restOptions,
env: {
...process.env,
...(env || {}),
},
};
return crossSpawn('npx', args, spawnOptions);
}
// =============================================================================
// Process Management
// =============================================================================
/**
* Get process IDs using a specific port (cross-platform)
* @param {number} port - Port number to check
* @returns {number[]} - Array of PIDs using the port
*/
export function getProcessesOnPort(port) {
const pids = new Set();
if (isWindows) {
try {
const output = execCommand(`netstat -ano | findstr :${port}`);
if (output) {
const lines = output.split('\n');
for (const line of lines) {
const match = line.match(/:\d+\s+.*?(\d+)\s*$/);
if (match) {
const pid = parseInt(match[1], 10);
if (pid > 0) pids.add(pid);
}
}
}
} catch {
// Ignore errors
}
} else {
try {
const output = execCommand(`lsof -ti:${port}`);
if (output) {
output.split('\n').forEach((pid) => {
const parsed = parseInt(pid.trim(), 10);
if (parsed > 0) pids.add(parsed);
});
}
} catch {
// Ignore errors
}
}
return Array.from(pids);
}
/**
* Kill a process by PID (cross-platform)
* @param {number} pid - Process ID to kill
* @returns {boolean} - Whether the kill succeeded
*/
export function killProcess(pid) {
try {
if (isWindows) {
execCommand(`taskkill /F /PID ${pid}`);
} else {
process.kill(pid, 'SIGKILL');
}
return true;
} catch {
return false;
}
}
/**
* Check if a port is in use (without killing)
* @param {number} port - Port number to check
* @returns {boolean} - Whether the port is in use
*/
export function isPortInUse(port) {
const pids = getProcessesOnPort(port);
return pids.length > 0;
}
/**
* Kill processes on a port and wait for it to be freed
* @param {number} port - Port number to free
* @returns {Promise<boolean>} - Whether the port was freed
*/
export async function killPort(port) {
const pids = getProcessesOnPort(port);
if (pids.length === 0) {
log(`✓ Port ${port} is available`, 'green');
return true;
}
log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow');
for (const pid of pids) {
killProcess(pid);
}
// Wait for port to be freed (max 5 seconds)
for (let i = 0; i < 10; i++) {
await sleep(500);
const remainingPids = getProcessesOnPort(port);
if (remainingPids.length === 0) {
log(`✓ Port ${port} is now free`, 'green');
return true;
}
}
log(`Warning: Port ${port} may still be in use`, 'red');
return false;
}
/**
* Kill a process tree using tree-kill
* @param {number} pid - Root process ID
* @returns {Promise<void>}
*/
export function killProcessTree(pid) {
return new Promise((resolve) => {
if (!pid) {
resolve();
return;
}
treeKill(pid, 'SIGTERM', (err) => {
if (err) {
treeKill(pid, 'SIGKILL', () => resolve());
} else {
resolve();
}
});
});
}
// =============================================================================
// Utilities
// =============================================================================
/**
* Sleep for a given number of milliseconds
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if the server health endpoint is responding
* @param {number} port - Server port (default 3008)
* @returns {Promise<boolean>} - Whether the server is healthy
*/
export function checkHealth(port = 3008) {
return new Promise((resolve) => {
const req = http.get(`http://localhost:${port}/api/health`, (res) => {
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.setTimeout(2000, () => {
req.destroy();
resolve(false);
});
});
}
/**
* Prompt the user for input
* @param {string} question - Question to ask
* @returns {Promise<string>} - User's answer
*/
export function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// =============================================================================
// Port Configuration Flow
// =============================================================================
/**
* Check ports and prompt user for resolution if in use
* @param {object} options - Configuration options
* @param {number} options.defaultWebPort - Default web port (3007)
* @param {number} options.defaultServerPort - Default server port (3008)
* @returns {Promise<{webPort: number, serverPort: number, corsOriginEnv: string}>}
*/
export async function resolvePortConfiguration({
defaultWebPort = 3007,
defaultServerPort = 3008,
} = {}) {
log(`Checking for processes on ports ${defaultWebPort} and ${defaultServerPort}...`, 'yellow');
const webPortInUse = isPortInUse(defaultWebPort);
const serverPortInUse = isPortInUse(defaultServerPort);
let webPort = defaultWebPort;
let serverPort = defaultServerPort;
if (webPortInUse || serverPortInUse) {
console.log('');
if (webPortInUse) {
const pids = getProcessesOnPort(defaultWebPort);
log(`⚠ Port ${defaultWebPort} is in use by process(es): ${pids.join(', ')}`, 'yellow');
}
if (serverPortInUse) {
const pids = getProcessesOnPort(defaultServerPort);
log(`⚠ Port ${defaultServerPort} is in use by process(es): ${pids.join(', ')}`, 'yellow');
}
console.log('');
while (true) {
const choice = await prompt(
'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: '
);
const lowerChoice = choice.toLowerCase();
if (lowerChoice === 'k' || lowerChoice === 'kill') {
if (webPortInUse) {
await killPort(defaultWebPort);
} else {
log(`✓ Port ${defaultWebPort} is available`, 'green');
}
if (serverPortInUse) {
await killPort(defaultServerPort);
} else {
log(`✓ Port ${defaultServerPort} is available`, 'green');
}
break;
} else if (lowerChoice === 'u' || lowerChoice === 'use') {
webPort = await promptForPort('web', defaultWebPort);
serverPort = await promptForPort('server', defaultServerPort, webPort);
log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue');
break;
} else if (lowerChoice === 'c' || lowerChoice === 'cancel') {
log('Cancelled.', 'yellow');
process.exit(0);
} else {
log(
'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).',
'red'
);
}
}
} else {
log(`✓ Port ${defaultWebPort} is available`, 'green');
log(`✓ Port ${defaultServerPort} is available`, 'green');
}
// Build CORS origin env
const existing = (process.env.CORS_ORIGIN || '')
.split(',')
.map((o) => o.trim())
.filter(Boolean)
.filter((o) => o !== '*');
const origins = new Set(existing);
origins.add(`http://localhost:${webPort}`);
origins.add(`http://127.0.0.1:${webPort}`);
const corsOriginEnv = Array.from(origins).join(',');
console.log('');
return { webPort, serverPort, corsOriginEnv };
}
/**
* Prompt for a specific port with validation
* @param {string} name - Port name (web/server)
* @param {number} defaultPort - Default port value
* @param {number} excludePort - Port to exclude (optional)
* @returns {Promise<number>}
*/
async function promptForPort(name, defaultPort, excludePort = null) {
while (true) {
const input = await prompt(`Enter ${name} port (default ${defaultPort}): `);
const parsed = input.trim() ? parseInt(input.trim(), 10) : defaultPort;
if (isNaN(parsed) || parsed < 1024 || parsed > 65535) {
log('Invalid port. Please enter a number between 1024 and 65535.', 'red');
continue;
}
if (excludePort && parsed === excludePort) {
log(`${name} port cannot be the same as the other port.`, 'red');
continue;
}
if (isPortInUse(parsed)) {
const pids = getProcessesOnPort(parsed);
log(`Port ${parsed} is already in use by process(es): ${pids.join(', ')}`, 'red');
const useAnyway = await prompt('Use this port anyway? (y/n): ');
if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') {
continue;
}
}
return parsed;
}
}
// =============================================================================
// UI Components
// =============================================================================
/**
* Print the application header banner
* @param {string} title - Header title
*/
export function printHeader(title) {
console.log('╔═══════════════════════════════════════════════════════╗');
console.log(`${title.padEnd(45)}`);
console.log('╚═══════════════════════════════════════════════════════╝');
console.log('');
}
/**
* Print the application mode menu
*/
export function printModeMenu() {
console.log('═══════════════════════════════════════════════════════');
console.log(' Select Application Mode:');
console.log('═══════════════════════════════════════════════════════');
console.log(' 1) Web Application (Browser)');
console.log(' 2) Desktop Application (Electron)');
console.log(' 3) Docker Container (Isolated)');
console.log('═══════════════════════════════════════════════════════');
console.log('');
}
// =============================================================================
// Process Cleanup
// =============================================================================
/**
* Create a cleanup handler for spawned processes
* @param {object} processes - Object with process references {server, web, electron, docker}
* @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));
}
if (processes.docker && !processes.docker.killed && processes.docker.pid) {
killPromises.push(killProcessTree(processes.docker.pid));
}
await Promise.all(killPromises);
};
}
/**
* Setup signal handlers for graceful shutdown
* @param {Function} cleanup - Cleanup function
*/
export function setupSignalHandlers(cleanup) {
let cleaningUp = false;
const handleExit = async () => {
if (cleaningUp) return;
cleaningUp = true;
await cleanup();
process.exit(0);
};
process.on('SIGINT', () => handleExit());
process.on('SIGTERM', () => handleExit());
}
// =============================================================================
// Server Startup
// =============================================================================
/**
* Start the backend server and wait for it to be ready
* @param {object} options - Configuration options
* @returns {Promise<ChildProcess>} - Server process
*/
export async function startServerAndWait({ serverPort, corsOriginEnv, npmArgs, cwd, fs, baseDir }) {
log(`Starting backend server on port ${serverPort}...`, 'blue');
// Create logs directory
const logsDir = path.join(baseDir, 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logStream = fs.createWriteStream(path.join(baseDir, 'logs', 'server.log'));
const serverProcess = runNpm(
npmArgs,
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
PORT: String(serverPort),
CORS_ORIGIN: corsOriginEnv,
},
},
cwd
);
// Pipe to both log file and console
serverProcess.stdout?.on('data', (data) => {
process.stdout.write(data);
logStream.write(data);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(data);
logStream.write(data);
});
log('Waiting for server to be ready...', 'yellow');
// Wait for server health check
const maxRetries = 30;
let serverReady = false;
for (let i = 0; i < maxRetries; i++) {
if (await checkHealth(serverPort)) {
serverReady = true;
break;
}
process.stdout.write('.');
await sleep(1000);
}
console.log('');
if (!serverReady) {
log('Error: Server failed to start', 'red');
console.log('Check logs/server.log for details');
// 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;
}
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}`));
});
});
}
}

281
start.mjs Executable file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* Automaker - Production Mode Launch Script
*
* This script runs the application in production mode (no Vite dev server).
* It builds everything if needed, then serves static files via vite preview.
*
* 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 path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
createRestrictedFs,
log,
runNpm,
runNpmAndWait,
runNpx,
printHeader,
printModeMenu,
resolvePortConfiguration,
createCleanupHandler,
setupSignalHandlers,
startServerAndWait,
ensureDependencies,
prompt,
killProcessTree,
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);
// Create restricted fs for this script's directory
const fs = createRestrictedFs(__dirname, 'start.mjs');
// Track background processes for cleanup
const processes = {
server: null,
web: null,
electron: null,
docker: null,
};
/**
* Build all production artifacts
*/
async function ensureProductionBuilds() {
// Always build shared packages first to ensure they're up to date
log('Building shared packages...', 'blue');
try {
await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname);
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' },
path.join(__dirname, 'apps', 'server')
);
log('✓ Server built', 'green');
} catch (error) {
log(`Failed to build server: ${error.message}`, 'red');
process.exit(1);
}
// 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);
}
}
/**
* Main function
*/
async function main() {
// Change to script directory
process.chdir(__dirname);
printHeader('Automaker Production Mode');
// Ensure dependencies are installed
await ensureDependencies(fs, __dirname);
// Build production artifacts if needed
await ensureProductionBuilds();
// 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, 2, or 3): ');
if (choice === '1') {
console.log('');
log('Launching Web Application (Production Mode)...', 'blue');
// 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'),
fs,
baseDir: __dirname,
});
if (!processes.server) {
await cleanup();
process.exit(1);
}
log(`Starting web server...`, 'blue');
// 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) => {
processes.web.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');
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 for vite preview to start
await sleep(2000);
// 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) => {
processes.electron.on('close', () => {
// Also kill vite preview when electron closes
if (processes.web && !processes.web.killed && processes.web.pid) {
killProcessTree(processes.web.pid);
}
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, 2, or 3.', 'red');
}
}
}
// Run main function
main().catch(async (err) => {
console.error(err);
const cleanup = createCleanupHandler(processes);
try {
await cleanup();
} catch (cleanupErr) {
console.error('Cleanup error:', cleanupErr);
}
process.exit(1);
});