diff --git a/server/routers/devserver.py b/server/routers/devserver.py index 673bc3ed..b6429a23 100644 --- a/server/routers/devserver.py +++ b/server/routers/devserver.py @@ -22,9 +22,13 @@ from ..services.dev_server_manager import get_devserver_manager from ..services.project_config import ( clear_dev_command, + detect_project_type, get_dev_command, + get_dev_port, get_project_config, + inject_port_into_command, set_dev_command, + set_dev_port, ) # Add root to path for registry import @@ -167,6 +171,11 @@ async def start_devserver( detail="No dev command available. Configure a custom command or ensure project type can be detected." ) + # Inject port into command if specified + if request.port is not None: + project_type = detect_project_type(project_dir) + command = inject_port_into_command(command, request.port, project_type) + # Now command is definitely str success, message = await manager.start(command) @@ -211,6 +220,7 @@ async def get_devserver_config(project_name: str) -> DevServerConfigResponse: - detected_command: The default command for the detected type - custom_command: Any user-configured custom command - effective_command: The command that will actually be used (custom or detected) + - port: The configured port number (if set) Args: project_name: Name of the project @@ -220,12 +230,14 @@ async def get_devserver_config(project_name: str) -> DevServerConfigResponse: """ project_dir = get_project_dir(project_name) config = get_project_config(project_dir) + configured_port = get_dev_port(project_dir) return DevServerConfigResponse( detected_type=config["detected_type"], detected_command=config["detected_command"], custom_command=config["custom_command"], effective_command=config["effective_command"], + port=configured_port, ) @@ -241,9 +253,12 @@ async def update_devserver_config( Set custom_command to null/None to clear the custom command and revert to using the auto-detected command. + Set port to an integer to configure a custom dev port. + Set port to null/None to clear the configured port. + Args: project_name: Name of the project - update: Configuration update containing the new custom_command + update: Configuration update containing the new custom_command and/or port Returns: Updated configuration details for the project's dev server @@ -269,12 +284,26 @@ async def update_devserver_config( detail=f"Failed to save configuration: {e}" ) + # Update the port if provided + if update.port is not None: + try: + set_dev_port(project_dir, update.port) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to save configuration: {e}" + ) + # Return updated config config = get_project_config(project_dir) + configured_port = get_dev_port(project_dir) return DevServerConfigResponse( detected_type=config["detected_type"], detected_command=config["detected_command"], custom_command=config["custom_command"], effective_command=config["effective_command"], + port=configured_port, ) diff --git a/server/schemas.py b/server/schemas.py index 844aaa11..6ce9fa22 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -420,6 +420,7 @@ def validate_testing_ratio(cls, v: int | None) -> int | None: class DevServerStartRequest(BaseModel): """Request schema for starting the dev server.""" command: str | None = None # If None, uses effective command from config + port: int | None = None # If provided, injects port into the command class DevServerStatus(BaseModel): @@ -444,11 +445,13 @@ class DevServerConfigResponse(BaseModel): detected_command: str | None = None custom_command: str | None = None effective_command: str | None = None + port: int | None = None class DevServerConfigUpdate(BaseModel): """Request schema for updating dev server configuration.""" custom_command: str | None = None # None clears the custom command + port: int | None = None # None clears the configured port # ============================================================================ diff --git a/server/services/project_config.py b/server/services/project_config.py index f6e50d07..3551d809 100644 --- a/server/services/project_config.py +++ b/server/services/project_config.py @@ -351,6 +351,48 @@ def get_dev_command(project_dir: Path) -> str | None: return get_default_dev_command(project_dir) +def inject_port_into_command(command: str, port: int, project_type: str | None) -> str: + """ + Inject a port number into a dev command based on project type. + + Different project types require different syntax for specifying ports: + - nodejs-vite: Append `-- --port {port}` to pass through npm scripts + - nodejs-cra: Prepend `PORT={port}` as environment variable + - python-django: Replace `runserver` with `runserver 0.0.0.0:{port}` + - python-fastapi/python-poetry: Append `--port {port}` for uvicorn + - rust/go: No modification (no standard port flag) + + Args: + command: The base dev command to modify. + port: The port number to inject. + project_type: The detected project type, or None if unknown. + + Returns: + The command with port injection applied, or the original command + if no port injection is applicable. + """ + if project_type == "nodejs-vite": + # npm run dev -- --port 4000 + return f"{command} -- --port {port}" + + if project_type == "nodejs-cra": + # PORT=4000 npm start (works cross-platform with npm) + return f"PORT={port} {command}" + + if project_type == "python-django": + # python manage.py runserver 0.0.0.0:4000 + if "runserver" in command: + return command.replace("runserver", f"runserver 0.0.0.0:{port}") + return command + + if project_type in ("python-fastapi", "python-poetry"): + # uvicorn commands: append --port flag + return f"{command} --port {port}" + + # rust, go, or unknown: return unmodified + return command + + def set_dev_command(project_dir: Path, command: str) -> None: """ Save a custom dev command for a project. @@ -376,6 +418,52 @@ def set_dev_command(project_dir: Path, command: str) -> None: logger.info("Set custom dev command for %s: %s", project_dir.name, command) +def get_dev_port(project_dir: Path) -> int | None: + """ + Get the configured dev port for a project. + + Args: + project_dir: Path to the project directory. + + Returns: + The configured port number, or None if no port is configured. + """ + project_dir = Path(project_dir).resolve() + config = _load_config(project_dir) + port = config.get("dev_port") + + if port is not None and isinstance(port, int): + return port + + return None + + +def set_dev_port(project_dir: Path, port: int) -> None: + """ + Save a custom dev port for a project. + + Args: + project_dir: Path to the project directory. + port: The port number to save (must be 1-65535). + + Raises: + ValueError: If port is not a valid integer in range 1-65535, + or if project_dir is invalid. + OSError: If the config file cannot be written. + """ + if not isinstance(port, int) or port < 1 or port > 65535: + raise ValueError("Port must be an integer between 1 and 65535") + + project_dir = _validate_project_dir(project_dir) + + # Load existing config and update + config = _load_config(project_dir) + config["dev_port"] = port + + _save_config(project_dir, config) + logger.info("Set custom dev port for %s: %d", project_dir.name, port) + + def clear_dev_command(project_dir: Path) -> None: """ Remove the custom dev command, reverting to auto-detection. diff --git a/ui/src/components/DevServerControl.tsx b/ui/src/components/DevServerControl.tsx index a6182c57..2f0c9e1d 100644 --- a/ui/src/components/DevServerControl.tsx +++ b/ui/src/components/DevServerControl.tsx @@ -1,4 +1,5 @@ -import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react' +import { useState } from 'react' +import { Globe, Square, Loader2, ExternalLink, AlertTriangle, X } from 'lucide-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import type { DevServerStatus } from '../lib/types' import { startDevServer, stopDevServer } from '../lib/api' @@ -18,7 +19,7 @@ function useStartDevServer(projectName: string) { const queryClient = useQueryClient() return useMutation({ - mutationFn: () => startDevServer(projectName), + mutationFn: (port?: number) => startDevServer(projectName, undefined, port), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] }) }, @@ -60,16 +61,28 @@ interface DevServerControlProps { * - Uses neobrutalism design with cyan accent when running */ export function DevServerControl({ projectName, status, url }: DevServerControlProps) { + const [showPortDialog, setShowPortDialog] = useState(false) + const [portValue, setPortValue] = useState(3000) const startDevServerMutation = useStartDevServer(projectName) const stopDevServerMutation = useStopDevServer(projectName) const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending - const handleStart = () => { - // Clear any previous errors before starting + const handleStartClick = () => { + // Clear any previous errors before showing dialog stopDevServerMutation.reset() - startDevServerMutation.mutate() + setShowPortDialog(true) } + + const handleConfirmStart = () => { + setShowPortDialog(false) + startDevServerMutation.mutate(portValue) + } + + const handleCancelStart = () => { + setShowPortDialog(false) + } + const handleStop = () => { // Clear any previous errors before stopping startDevServerMutation.reset() @@ -84,10 +97,10 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP const isCrashed = status === 'crashed' return ( -