From e110c058a26b5040f1d8500f6f1111deb10af4b1 Mon Sep 17 00:00:00 2001 From: Shirone Date: Thu, 22 Jan 2026 17:13:16 +0100 Subject: [PATCH] feat: enhance dev server configuration and command handling - Updated the `/start-dev` route to accept a custom development command from project settings, allowing for greater flexibility in starting dev servers. - Implemented a new `parseCustomCommand` method in the `DevServerService` to handle custom command parsing, including support for quoted strings. - Added a new `DevServerSection` component in the UI for configuring the dev server command, featuring quick presets and auto-detection options. - Updated project settings interface to include a `devCommand` property for storing custom commands. This update improves the user experience by allowing users to specify custom commands for their development servers, enhancing the overall development workflow. --- apps/server/src/routes/worktree/index.ts | 2 +- .../src/routes/worktree/routes/start-dev.ts | 33 ++- .../server/src/services/dev-server-service.ts | 92 +++++-- .../config/navigation.ts | 2 + .../dev-server-section.tsx | 256 ++++++++++++++++++ .../hooks/use-project-settings-view.ts | 1 + .../project-settings-view.tsx | 3 + libs/types/src/settings.ts | 8 + 8 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 apps/ui/src/components/views/project-settings-view/dev-server-section.tsx diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 94d64e1b..992a7b48 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -134,7 +134,7 @@ export function createWorktreeRoutes( router.post( '/start-dev', validatePathParams('projectPath', 'worktreePath'), - createStartDevHandler() + createStartDevHandler(settingsService) ); router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); diff --git a/apps/server/src/routes/worktree/routes/start-dev.ts b/apps/server/src/routes/worktree/routes/start-dev.ts index 13b93f9b..4bb111e8 100644 --- a/apps/server/src/routes/worktree/routes/start-dev.ts +++ b/apps/server/src/routes/worktree/routes/start-dev.ts @@ -1,16 +1,22 @@ /** * POST /start-dev endpoint - Start a dev server for a worktree * - * Spins up a development server (npm run dev) in the worktree directory - * on a unique port, allowing preview of the worktree's changes without - * affecting the main dev server. + * Spins up a development server in the worktree directory on a unique port, + * allowing preview of the worktree's changes without affecting the main dev server. + * + * If a custom devCommand is configured in project settings, it will be used. + * Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used. */ import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; import { getDevServerService } from '../../../services/dev-server-service.js'; import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; -export function createStartDevHandler() { +const logger = createLogger('start-dev'); + +export function createStartDevHandler(settingsService?: SettingsService) { return async (req: Request, res: Response): Promise => { try { const { projectPath, worktreePath } = req.body as { @@ -34,8 +40,25 @@ export function createStartDevHandler() { return; } + // Get custom dev command from project settings (if configured) + let customCommand: string | undefined; + if (settingsService) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + const devCommand = projectSettings?.devCommand?.trim(); + if (devCommand) { + customCommand = devCommand; + logger.debug(`Using custom dev command from project settings: ${customCommand}`); + } else { + logger.debug('No custom dev command configured, using auto-detection'); + } + } + const devServerService = getDevServerService(); - const result = await devServerService.startDevServer(projectPath, worktreePath); + const result = await devServerService.startDevServer( + projectPath, + worktreePath, + customCommand + ); if (result.success && result.result) { res.json({ diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 49f5218c..5e4cb947 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -273,12 +273,56 @@ class DevServerService { } } + /** + * Parse a custom command string into cmd and args + * Handles quoted strings with spaces (e.g., "my command" arg1 arg2) + */ + private parseCustomCommand(command: string): { cmd: string; args: string[] } { + const tokens: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + + if (inQuote) { + if (char === quoteChar) { + inQuote = false; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = true; + quoteChar = char; + } else if (char === ' ') { + if (current) { + tokens.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + tokens.push(current); + } + + const [cmd, ...args] = tokens; + return { cmd: cmd || '', args }; + } + /** * Start a dev server for a worktree + * @param projectPath - The project root path + * @param worktreePath - The worktree directory path + * @param customCommand - Optional custom command to run instead of auto-detected dev command */ async startDevServer( projectPath: string, - worktreePath: string + worktreePath: string, + customCommand?: string ): Promise<{ success: boolean; result?: { @@ -311,22 +355,38 @@ class DevServerService { }; } - // Check for package.json - const packageJsonPath = path.join(worktreePath, 'package.json'); - if (!(await this.fileExists(packageJsonPath))) { - return { - success: false, - error: `No package.json found in: ${worktreePath}`, - }; - } + // Determine the dev command to use + let devCommand: { cmd: string; args: string[] }; - // Get dev command - const devCommand = await this.getDevCommand(worktreePath); - if (!devCommand) { - return { - success: false, - error: `Could not determine dev command for: ${worktreePath}`, - }; + if (customCommand) { + // Use the provided custom command + devCommand = this.parseCustomCommand(customCommand); + if (!devCommand.cmd) { + return { + success: false, + error: 'Invalid custom command: command cannot be empty', + }; + } + logger.debug(`Using custom command: ${customCommand}`); + } else { + // Check for package.json when auto-detecting + const packageJsonPath = path.join(worktreePath, 'package.json'); + if (!(await this.fileExists(packageJsonPath))) { + return { + success: false, + error: `No package.json found in: ${worktreePath}`, + }; + } + + // Get dev command from package manager detection + const detectedCommand = await this.getDevCommand(worktreePath); + if (!detectedCommand) { + return { + success: false, + error: `Could not determine dev command for: ${worktreePath}`, + }; + } + devCommand = detectedCommand; } // Find available port diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts index 14054305..1e9cde9d 100644 --- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -7,6 +7,7 @@ import { Workflow, Database, FlaskConical, + Play, } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; @@ -20,6 +21,7 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ { id: 'identity', label: 'Identity', icon: User }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'testing', label: 'Testing', icon: FlaskConical }, + { id: 'devServer', label: 'Dev Server', icon: Play }, { id: 'theme', label: 'Theme', icon: Palette }, { id: 'claude', label: 'Models', icon: Workflow }, { id: 'data', label: 'Data', icon: Database }, diff --git a/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx b/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx new file mode 100644 index 00000000..136cad76 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/dev-server-section.tsx @@ -0,0 +1,256 @@ +import { useState, useEffect, useCallback, type KeyboardEvent } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Play, Save, RotateCcw, Info, X } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import type { Project } from '@/lib/electron'; + +/** Preset dev server commands for quick selection */ +const DEV_SERVER_PRESETS = [ + { label: 'npm run dev', command: 'npm run dev' }, + { label: 'yarn dev', command: 'yarn dev' }, + { label: 'pnpm dev', command: 'pnpm dev' }, + { label: 'bun dev', command: 'bun dev' }, + { label: 'npm start', command: 'npm start' }, + { label: 'cargo watch', command: 'cargo watch -x run' }, + { label: 'go run', command: 'go run .' }, +] as const; + +interface DevServerSectionProps { + project: Project; +} + +export function DevServerSection({ project }: DevServerSectionProps) { + const [devCommand, setDevCommand] = useState(''); + const [originalDevCommand, setOriginalDevCommand] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // Check if there are unsaved changes + const hasChanges = devCommand !== originalDevCommand; + + // Load project settings when project changes + useEffect(() => { + let isCancelled = false; + const currentPath = project.path; + + const loadProjectSettings = async () => { + setIsLoading(true); + try { + const httpClient = getHttpApiClient(); + const response = await httpClient.settings.getProject(currentPath); + + // Avoid updating state if component unmounted or project changed + if (isCancelled) return; + + if (response.success && response.settings) { + const command = response.settings.devCommand || ''; + setDevCommand(command); + setOriginalDevCommand(command); + } + } catch (error) { + if (!isCancelled) { + console.error('Failed to load project settings:', error); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + loadProjectSettings(); + + return () => { + isCancelled = true; + }; + }, [project.path]); + + // Save dev command + const handleSave = useCallback(async () => { + setIsSaving(true); + try { + const httpClient = getHttpApiClient(); + const normalizedCommand = devCommand.trim(); + const response = await httpClient.settings.updateProject(project.path, { + devCommand: normalizedCommand || undefined, + }); + + if (response.success) { + setDevCommand(normalizedCommand); + setOriginalDevCommand(normalizedCommand); + toast.success('Dev server command saved'); + } else { + toast.error('Failed to save dev server command', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to save dev server command:', error); + toast.error('Failed to save dev server command'); + } finally { + setIsSaving(false); + } + }, [project.path, devCommand]); + + // Reset to original value + const handleReset = useCallback(() => { + setDevCommand(originalDevCommand); + }, [originalDevCommand]); + + // Use a preset command + const handleUsePreset = useCallback((command: string) => { + setDevCommand(command); + }, []); + + // Clear the command to use auto-detection + const handleClear = useCallback(() => { + setDevCommand(''); + }, []); + + // Handle keyboard shortcuts (Enter to save) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && hasChanges && !isSaving) { + e.preventDefault(); + handleSave(); + } + }, + [hasChanges, isSaving, handleSave] + ); + + return ( +
+
+
+
+ +
+

+ Dev Server Configuration +

+
+

+ Configure how the development server is started for this project. +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Dev Command Input */} +
+
+ + {hasChanges && ( + (unsaved changes) + )} +
+
+ setDevCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm run dev, yarn dev, cargo watch, go run ." + className="font-mono text-sm pr-8" + data-testid="dev-command-input" + /> + {devCommand && ( + + )} +
+

+ The command to start the development server for this project. If not specified, the + system will auto-detect based on your package manager (npm/yarn/pnpm/bun run dev). +

+
+ + {/* Auto-detection Info */} +
+ +
+

Auto-detection

+

+ When no custom command is set, the dev server automatically detects your package + manager (npm, yarn, pnpm, or bun) and runs the "dev" script. Set a + custom command if your project uses a different script name (e.g., start, serve) + or requires additional flags. +

+
+
+ + {/* Quick Presets */} +
+ +
+ {DEV_SERVER_PRESETS.map((preset) => ( + + ))} +
+

+ Click a preset to use it as your dev server command. Press Enter to save. +

+
+ + {/* Action Buttons */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts index c93ae311..4241f84a 100644 --- a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -5,6 +5,7 @@ export type ProjectSettingsViewId = | 'theme' | 'worktrees' | 'testing' + | 'devServer' | 'claude' | 'data' | 'danger'; diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index c2868908..e97365cc 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -6,6 +6,7 @@ import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; import { TestingSection } from './testing-section'; +import { DevServerSection } from './dev-server-section'; import { ProjectModelsSection } from './project-models-section'; import { DataManagementSection } from './data-management-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; @@ -89,6 +90,8 @@ export function ProjectSettingsView() { return ; case 'testing': return ; + case 'devServer': + return ; case 'claude': return ; case 'data': diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index c33036eb..54ada432 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1190,6 +1190,14 @@ export interface ProjectSettings { */ testCommand?: string; + // Dev Server Configuration + /** + * Custom command to start the development server for this project. + * If not specified, auto-detection will be used based on project structure. + * Examples: "npm run dev", "yarn dev", "pnpm dev", "cargo watch", "go run ." + */ + devCommand?: string; + // Phase Model Overrides (per-project) /** * Override phase model settings for this project.