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..74ed8220 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,41 @@ 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}`, - }; + // Normalize custom command: trim whitespace and treat empty strings as undefined + const normalizedCustomCommand = customCommand?.trim(); + + if (normalizedCustomCommand) { + // Use the provided custom command + devCommand = this.parseCustomCommand(normalizedCustomCommand); + if (!devCommand.cmd) { + return { + success: false, + error: 'Invalid custom command: command cannot be empty', + }; + } + logger.debug(`Using custom command: ${normalizedCustomCommand}`); + } 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/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index d436dc8f..6ffdd488 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -852,6 +852,20 @@ export class SettingsService { delete updated.defaultFeatureModel; } + // Handle devCommand special cases: + // - null means delete the key (use auto-detection) + // - string means custom command + if ('devCommand' in updates && updates.devCommand === null) { + delete updated.devCommand; + } + + // Handle testCommand special cases: + // - null means delete the key (use auto-detection) + // - string means custom command + if ('testCommand' in updates && updates.testCommand === null) { + delete updated.testCommand; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); diff --git a/apps/ui/src/components/views/project-settings-view/commands-section.tsx b/apps/ui/src/components/views/project-settings-view/commands-section.tsx new file mode 100644 index 00000000..6577c07c --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/commands-section.tsx @@ -0,0 +1,316 @@ +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 { Terminal, Save, RotateCcw, Info, X, Play, FlaskConical } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { useProjectSettings } from '@/hooks/queries'; +import { useUpdateProjectSettings } from '@/hooks/mutations'; +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; + +/** Preset test commands for quick selection */ +const TEST_PRESETS = [ + { label: 'npm test', command: 'npm test' }, + { label: 'yarn test', command: 'yarn test' }, + { label: 'pnpm test', command: 'pnpm test' }, + { label: 'bun test', command: 'bun test' }, + { label: 'pytest', command: 'pytest' }, + { label: 'cargo test', command: 'cargo test' }, + { label: 'go test', command: 'go test ./...' }, +] as const; + +interface CommandsSectionProps { + project: Project; +} + +export function CommandsSection({ project }: CommandsSectionProps) { + // Fetch project settings using TanStack Query + const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path); + + // Mutation hook for updating project settings + const updateSettingsMutation = useUpdateProjectSettings(project.path); + + // Local state for the input fields + const [devCommand, setDevCommand] = useState(''); + const [originalDevCommand, setOriginalDevCommand] = useState(''); + const [testCommand, setTestCommand] = useState(''); + const [originalTestCommand, setOriginalTestCommand] = useState(''); + + // Sync local state when project settings load or project changes + useEffect(() => { + // Reset local state when project changes to avoid showing stale values + setDevCommand(''); + setOriginalDevCommand(''); + setTestCommand(''); + setOriginalTestCommand(''); + }, [project.path]); + + useEffect(() => { + if (projectSettings) { + const dev = projectSettings.devCommand || ''; + const test = projectSettings.testCommand || ''; + setDevCommand(dev); + setOriginalDevCommand(dev); + setTestCommand(test); + setOriginalTestCommand(test); + } + }, [projectSettings]); + + // Check if there are unsaved changes + const hasDevChanges = devCommand !== originalDevCommand; + const hasTestChanges = testCommand !== originalTestCommand; + const hasChanges = hasDevChanges || hasTestChanges; + const isSaving = updateSettingsMutation.isPending; + + // Save all commands + const handleSave = useCallback(() => { + const normalizedDevCommand = devCommand.trim(); + const normalizedTestCommand = testCommand.trim(); + + updateSettingsMutation.mutate( + { + devCommand: normalizedDevCommand || null, + testCommand: normalizedTestCommand || null, + }, + { + onSuccess: () => { + setDevCommand(normalizedDevCommand); + setOriginalDevCommand(normalizedDevCommand); + setTestCommand(normalizedTestCommand); + setOriginalTestCommand(normalizedTestCommand); + }, + } + ); + }, [devCommand, testCommand, updateSettingsMutation]); + + // Reset to original values + const handleReset = useCallback(() => { + setDevCommand(originalDevCommand); + setTestCommand(originalTestCommand); + }, [originalDevCommand, originalTestCommand]); + + // Use a preset command + const handleUseDevPreset = useCallback((command: string) => { + setDevCommand(command); + }, []); + + const handleUseTestPreset = useCallback((command: string) => { + setTestCommand(command); + }, []); + + // Clear commands + const handleClearDev = useCallback(() => { + setDevCommand(''); + }, []); + + const handleClearTest = useCallback(() => { + setTestCommand(''); + }, []); + + // Handle keyboard shortcuts (Enter to save) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && hasChanges && !isSaving) { + e.preventDefault(); + handleSave(); + } + }, + [hasChanges, isSaving, handleSave] + ); + + return ( +
+
+
+
+ +
+

Project Commands

+
+

+ Configure custom commands for development and testing. +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Failed to load project settings. Please try again. +
+ ) : ( + <> + {/* Dev Server Command Section */} +
+
+ +

Dev Server

+ {hasDevChanges && ( + (unsaved) + )} +
+ +
+
+ setDevCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm run dev, yarn dev, cargo watch" + className="font-mono text-sm pr-8" + data-testid="dev-command-input" + /> + {devCommand && ( + + )} +
+

+ Leave empty to auto-detect based on your package manager. +

+ + {/* Dev Presets */} +
+ {DEV_SERVER_PRESETS.map((preset) => ( + + ))} +
+
+
+ + {/* Divider */} +
+ + {/* Test Command Section */} +
+
+ +

Test Runner

+ {hasTestChanges && ( + (unsaved) + )} +
+ +
+
+ setTestCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm test, pytest, cargo test" + className="font-mono text-sm pr-8" + data-testid="test-command-input" + /> + {testCommand && ( + + )} +
+

+ Leave empty to auto-detect based on your project structure. +

+ + {/* Test Presets */} +
+ {TEST_PRESETS.map((preset) => ( + + ))} +
+
+
+ + {/* Auto-detection Info */} +
+ +
+

Auto-detection

+

+ When no custom command is set, the system automatically detects your package + manager and test framework based on project files (package.json, Cargo.toml, + go.mod, etc.). +

+
+
+ + {/* Action Buttons */} +
+ + +
+ + )} +
+
+ ); +} 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..93eba60b 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 @@ -6,7 +6,7 @@ import { AlertTriangle, Workflow, Database, - FlaskConical, + Terminal, } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; @@ -19,7 +19,7 @@ export interface ProjectNavigationItem { 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: 'commands', label: 'Commands', icon: Terminal }, { 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/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts index c93ae311..02a09908 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 @@ -4,7 +4,7 @@ export type ProjectSettingsViewId = | 'identity' | 'theme' | 'worktrees' - | 'testing' + | 'commands' | 'claude' | 'data' | 'danger'; diff --git a/apps/ui/src/components/views/project-settings-view/index.ts b/apps/ui/src/components/views/project-settings-view/index.ts index 1e70ea79..fe8fb22b 100644 --- a/apps/ui/src/components/views/project-settings-view/index.ts +++ b/apps/ui/src/components/views/project-settings-view/index.ts @@ -2,6 +2,6 @@ export { ProjectSettingsView } from './project-settings-view'; export { ProjectIdentitySection } from './project-identity-section'; export { ProjectThemeSection } from './project-theme-section'; export { WorktreePreferencesSection } from './worktree-preferences-section'; -export { TestingSection } from './testing-section'; +export { CommandsSection } from './commands-section'; export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks'; export { ProjectSettingsNavigation } from './components/project-settings-navigation'; 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..b57f3b8f 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 @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; -import { TestingSection } from './testing-section'; +import { CommandsSection } from './commands-section'; import { ProjectModelsSection } from './project-models-section'; import { DataManagementSection } from './data-management-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; @@ -87,8 +87,8 @@ export function ProjectSettingsView() { return ; case 'worktrees': return ; - case 'testing': - return ; + case 'commands': + return ; case 'claude': return ; case 'data': diff --git a/apps/ui/src/components/views/project-settings-view/testing-section.tsx b/apps/ui/src/components/views/project-settings-view/testing-section.tsx deleted file mode 100644 index c457145f..00000000 --- a/apps/ui/src/components/views/project-settings-view/testing-section.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { FlaskConical, Save, RotateCcw, Info } 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'; - -interface TestingSectionProps { - project: Project; -} - -export function TestingSection({ project }: TestingSectionProps) { - const [testCommand, setTestCommand] = useState(''); - const [originalTestCommand, setOriginalTestCommand] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - - // Check if there are unsaved changes - const hasChanges = testCommand !== originalTestCommand; - - // 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.testCommand || ''; - setTestCommand(command); - setOriginalTestCommand(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 test command - const handleSave = useCallback(async () => { - setIsSaving(true); - try { - const httpClient = getHttpApiClient(); - const normalizedCommand = testCommand.trim(); - const response = await httpClient.settings.updateProject(project.path, { - testCommand: normalizedCommand || undefined, - }); - - if (response.success) { - setTestCommand(normalizedCommand); - setOriginalTestCommand(normalizedCommand); - toast.success('Test command saved'); - } else { - toast.error('Failed to save test command', { - description: response.error, - }); - } - } catch (error) { - console.error('Failed to save test command:', error); - toast.error('Failed to save test command'); - } finally { - setIsSaving(false); - } - }, [project.path, testCommand]); - - // Reset to original value - const handleReset = useCallback(() => { - setTestCommand(originalTestCommand); - }, [originalTestCommand]); - - // Use a preset command - const handleUsePreset = useCallback((command: string) => { - setTestCommand(command); - }, []); - - return ( -
-
-
-
- -
-

- Testing Configuration -

-
-

- Configure how tests are run for this project. -

-
- -
- {isLoading ? ( -
- -
- ) : ( - <> - {/* Test Command Input */} -
-
- - {hasChanges && ( - (unsaved changes) - )} -
- setTestCommand(e.target.value)} - placeholder="e.g., npm test, yarn test, pytest, go test ./..." - className="font-mono text-sm" - data-testid="test-command-input" - /> -

- The command to run tests for this project. If not specified, the test runner will - auto-detect based on your project structure (package.json, Cargo.toml, go.mod, - etc.). -

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

Auto-detection

-

- When no custom command is set, the test runner automatically detects and uses the - appropriate test framework based on your project files (Vitest, Jest, Pytest, - Cargo, Go Test, etc.). -

-
-
- - {/* Quick Presets */} -
- -
- {[ - { label: 'npm test', command: 'npm test' }, - { label: 'yarn test', command: 'yarn test' }, - { label: 'pnpm test', command: 'pnpm test' }, - { label: 'bun test', command: 'bun test' }, - { label: 'pytest', command: 'pytest' }, - { label: 'cargo test', command: 'cargo test' }, - { label: 'go test', command: 'go test ./...' }, - ].map((preset) => ( - - ))} -
-

- Click a preset to use it as your test command. -

-
- - {/* Action Buttons */} -
- - -
- - )} -
-
- ); -} 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.