Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion server/routers/devserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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,
)


Expand All @@ -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
Expand All @@ -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,
)
3 changes: 3 additions & 0 deletions server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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


# ============================================================================
Expand Down
88 changes: 88 additions & 0 deletions server/services/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
74 changes: 67 additions & 7 deletions ui/src/components/DevServerControl.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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] })
},
Expand Down Expand Up @@ -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()
Expand All @@ -84,10 +97,10 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
const isCrashed = status === 'crashed'

return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative">
{isStopped ? (
<button
onClick={handleStart}
onClick={handleStartClick}
disabled={isLoading}
className="neo-btn text-sm py-2 px-3"
style={isCrashed ? {
Expand Down Expand Up @@ -125,6 +138,53 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
</button>
)}

{/* Port selection dialog */}
{showPortDialog && (
<div className="absolute top-full left-0 mt-2 z-50 neo-card p-3 min-w-[200px]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold">Port</span>
<button
onClick={handleCancelStart}
className="p-1 hover:bg-[var(--color-neo-bg-secondary)] rounded"
aria-label="Cancel"
>
<X size={14} />
</button>
</div>
<input
type="number"
value={portValue}
onChange={(e) => setPortValue(parseInt(e.target.value) || 3000)}
className="w-full px-2 py-1 text-sm font-mono border-2 border-[var(--color-neo-border)] rounded mb-2"
min={1}
max={65535}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmStart()
if (e.key === 'Escape') handleCancelStart()
}}
/>
<div className="flex gap-2">
<button
onClick={handleConfirmStart}
className="neo-btn text-sm py-1 px-3 flex-1"
style={{
backgroundColor: 'var(--color-neo-progress)',
color: 'var(--color-neo-text-on-bright)',
}}
>
Start
</button>
<button
onClick={handleCancelStart}
className="neo-btn text-sm py-1 px-3"
>
Cancel
</button>
</div>
</div>
)}

{/* Show URL as clickable link when server is running */}
{isRunning && url && (
<a
Expand Down
5 changes: 3 additions & 2 deletions ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,11 +392,12 @@ export async function getDevServerStatus(projectName: string): Promise<DevServer

export async function startDevServer(
projectName: string,
command?: string
command?: string,
port?: number
): Promise<{ success: boolean; message: string }> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/start`, {
method: 'POST',
body: JSON.stringify({ command }),
body: JSON.stringify({ command, port }),
})
}

Expand Down