mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
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.
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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<void> => {
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[hasChanges, isSaving, handleSave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Play className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Dev Server Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure how the development server is started for this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Dev Command Input */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="dev-command" className="text-foreground font-medium">
|
||||
Dev Server Command
|
||||
</Label>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-500 font-medium">(unsaved changes)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="dev-command"
|
||||
value={devCommand}
|
||||
onChange={(e) => 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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
title="Clear to use auto-detection"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-detection Info */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Quick Presets</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DEV_SERVER_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUsePreset(preset.command)}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Click a preset to use it as your dev server command. Press Enter to save.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type ProjectSettingsViewId =
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'testing'
|
||||
| 'devServer'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
@@ -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 <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'testing':
|
||||
return <TestingSection project={currentProject} />;
|
||||
case 'devServer':
|
||||
return <DevServerSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user