autoforge-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assistant Chat Router
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
WebSocket and REST endpoints for the read-only project assistant.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from ..services.assistant_chat_session import (
|
|
16
|
+
AssistantChatSession,
|
|
17
|
+
create_session,
|
|
18
|
+
get_session,
|
|
19
|
+
list_sessions,
|
|
20
|
+
remove_session,
|
|
21
|
+
)
|
|
22
|
+
from ..services.assistant_database import (
|
|
23
|
+
create_conversation,
|
|
24
|
+
delete_conversation,
|
|
25
|
+
get_conversation,
|
|
26
|
+
get_conversations,
|
|
27
|
+
)
|
|
28
|
+
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
29
|
+
from ..utils.validation import is_valid_project_name as validate_project_name
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/api/assistant", tags=["assistant-chat"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ============================================================================
|
|
37
|
+
# Pydantic Models
|
|
38
|
+
# ============================================================================
|
|
39
|
+
|
|
40
|
+
class ConversationSummary(BaseModel):
|
|
41
|
+
"""Summary of a conversation."""
|
|
42
|
+
id: int
|
|
43
|
+
project_name: str
|
|
44
|
+
title: Optional[str]
|
|
45
|
+
created_at: Optional[str]
|
|
46
|
+
updated_at: Optional[str]
|
|
47
|
+
message_count: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConversationMessageModel(BaseModel):
|
|
51
|
+
"""A message within a conversation."""
|
|
52
|
+
id: int
|
|
53
|
+
role: str
|
|
54
|
+
content: str
|
|
55
|
+
timestamp: Optional[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConversationDetail(BaseModel):
|
|
59
|
+
"""Full conversation with messages."""
|
|
60
|
+
id: int
|
|
61
|
+
project_name: str
|
|
62
|
+
title: Optional[str]
|
|
63
|
+
created_at: Optional[str]
|
|
64
|
+
updated_at: Optional[str]
|
|
65
|
+
messages: list[ConversationMessageModel]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SessionInfo(BaseModel):
|
|
69
|
+
"""Active session information."""
|
|
70
|
+
project_name: str
|
|
71
|
+
conversation_id: Optional[int]
|
|
72
|
+
is_active: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ============================================================================
|
|
76
|
+
# REST Endpoints - Conversation Management
|
|
77
|
+
# ============================================================================
|
|
78
|
+
|
|
79
|
+
@router.get("/conversations/{project_name}", response_model=list[ConversationSummary])
|
|
80
|
+
async def list_project_conversations(project_name: str):
|
|
81
|
+
"""List all conversations for a project."""
|
|
82
|
+
if not validate_project_name(project_name):
|
|
83
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
84
|
+
|
|
85
|
+
project_dir = _get_project_path(project_name)
|
|
86
|
+
if not project_dir or not project_dir.exists():
|
|
87
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
88
|
+
|
|
89
|
+
conversations = get_conversations(project_dir, project_name)
|
|
90
|
+
return [ConversationSummary(**c) for c in conversations]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get("/conversations/{project_name}/{conversation_id}", response_model=ConversationDetail)
|
|
94
|
+
async def get_project_conversation(project_name: str, conversation_id: int):
|
|
95
|
+
"""Get a specific conversation with all messages."""
|
|
96
|
+
if not validate_project_name(project_name):
|
|
97
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
98
|
+
|
|
99
|
+
project_dir = _get_project_path(project_name)
|
|
100
|
+
if not project_dir or not project_dir.exists():
|
|
101
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
102
|
+
|
|
103
|
+
conversation = get_conversation(project_dir, conversation_id)
|
|
104
|
+
if not conversation:
|
|
105
|
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
106
|
+
|
|
107
|
+
return ConversationDetail(
|
|
108
|
+
id=conversation["id"],
|
|
109
|
+
project_name=conversation["project_name"],
|
|
110
|
+
title=conversation["title"],
|
|
111
|
+
created_at=conversation["created_at"],
|
|
112
|
+
updated_at=conversation["updated_at"],
|
|
113
|
+
messages=[ConversationMessageModel(**m) for m in conversation["messages"]],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.post("/conversations/{project_name}", response_model=ConversationSummary)
|
|
118
|
+
async def create_project_conversation(project_name: str):
|
|
119
|
+
"""Create a new conversation for a project."""
|
|
120
|
+
if not validate_project_name(project_name):
|
|
121
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
122
|
+
|
|
123
|
+
project_dir = _get_project_path(project_name)
|
|
124
|
+
if not project_dir or not project_dir.exists():
|
|
125
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
126
|
+
|
|
127
|
+
conversation = create_conversation(project_dir, project_name)
|
|
128
|
+
return ConversationSummary(
|
|
129
|
+
id=int(conversation.id),
|
|
130
|
+
project_name=str(conversation.project_name),
|
|
131
|
+
title=str(conversation.title) if conversation.title else None,
|
|
132
|
+
created_at=conversation.created_at.isoformat() if conversation.created_at else None,
|
|
133
|
+
updated_at=conversation.updated_at.isoformat() if conversation.updated_at else None,
|
|
134
|
+
message_count=0,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.delete("/conversations/{project_name}/{conversation_id}")
|
|
139
|
+
async def delete_project_conversation(project_name: str, conversation_id: int):
|
|
140
|
+
"""Delete a conversation."""
|
|
141
|
+
if not validate_project_name(project_name):
|
|
142
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
143
|
+
|
|
144
|
+
project_dir = _get_project_path(project_name)
|
|
145
|
+
if not project_dir or not project_dir.exists():
|
|
146
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
147
|
+
|
|
148
|
+
success = delete_conversation(project_dir, conversation_id)
|
|
149
|
+
if not success:
|
|
150
|
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
151
|
+
|
|
152
|
+
return {"success": True, "message": "Conversation deleted"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ============================================================================
|
|
156
|
+
# REST Endpoints - Session Management
|
|
157
|
+
# ============================================================================
|
|
158
|
+
|
|
159
|
+
@router.get("/sessions", response_model=list[str])
|
|
160
|
+
async def list_active_sessions():
|
|
161
|
+
"""List all active assistant sessions."""
|
|
162
|
+
return list_sessions()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.get("/sessions/{project_name}", response_model=SessionInfo)
|
|
166
|
+
async def get_session_info(project_name: str):
|
|
167
|
+
"""Get information about an active session."""
|
|
168
|
+
if not validate_project_name(project_name):
|
|
169
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
170
|
+
|
|
171
|
+
session = get_session(project_name)
|
|
172
|
+
if not session:
|
|
173
|
+
raise HTTPException(status_code=404, detail="No active session for this project")
|
|
174
|
+
|
|
175
|
+
return SessionInfo(
|
|
176
|
+
project_name=project_name,
|
|
177
|
+
conversation_id=session.get_conversation_id(),
|
|
178
|
+
is_active=True,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@router.delete("/sessions/{project_name}")
|
|
183
|
+
async def close_session(project_name: str):
|
|
184
|
+
"""Close an active session."""
|
|
185
|
+
if not validate_project_name(project_name):
|
|
186
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
187
|
+
|
|
188
|
+
session = get_session(project_name)
|
|
189
|
+
if not session:
|
|
190
|
+
raise HTTPException(status_code=404, detail="No active session for this project")
|
|
191
|
+
|
|
192
|
+
await remove_session(project_name)
|
|
193
|
+
return {"success": True, "message": "Session closed"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ============================================================================
|
|
197
|
+
# WebSocket Endpoint
|
|
198
|
+
# ============================================================================
|
|
199
|
+
|
|
200
|
+
@router.websocket("/ws/{project_name}")
|
|
201
|
+
async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
|
|
202
|
+
"""
|
|
203
|
+
WebSocket endpoint for assistant chat.
|
|
204
|
+
|
|
205
|
+
Message protocol:
|
|
206
|
+
|
|
207
|
+
Client -> Server:
|
|
208
|
+
- {"type": "start", "conversation_id": int | null} - Start/resume session
|
|
209
|
+
- {"type": "message", "content": "..."} - Send user message
|
|
210
|
+
- {"type": "ping"} - Keep-alive ping
|
|
211
|
+
|
|
212
|
+
Server -> Client:
|
|
213
|
+
- {"type": "conversation_created", "conversation_id": int} - New conversation created
|
|
214
|
+
- {"type": "text", "content": "..."} - Text chunk from Claude
|
|
215
|
+
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
|
|
216
|
+
- {"type": "response_done"} - Response complete
|
|
217
|
+
- {"type": "error", "content": "..."} - Error message
|
|
218
|
+
- {"type": "pong"} - Keep-alive pong
|
|
219
|
+
"""
|
|
220
|
+
if not validate_project_name(project_name):
|
|
221
|
+
await websocket.close(code=4000, reason="Invalid project name")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
project_dir = _get_project_path(project_name)
|
|
225
|
+
if not project_dir:
|
|
226
|
+
await websocket.close(code=4004, reason="Project not found in registry")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
if not project_dir.exists():
|
|
230
|
+
await websocket.close(code=4004, reason="Project directory not found")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
await websocket.accept()
|
|
234
|
+
logger.info(f"Assistant WebSocket connected for project: {project_name}")
|
|
235
|
+
|
|
236
|
+
session: Optional[AssistantChatSession] = None
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
data = await websocket.receive_text()
|
|
242
|
+
message = json.loads(data)
|
|
243
|
+
msg_type = message.get("type")
|
|
244
|
+
logger.debug(f"Assistant received message type: {msg_type}")
|
|
245
|
+
|
|
246
|
+
if msg_type == "ping":
|
|
247
|
+
await websocket.send_json({"type": "pong"})
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
elif msg_type == "start":
|
|
251
|
+
# Get optional conversation_id to resume
|
|
252
|
+
conversation_id = message.get("conversation_id")
|
|
253
|
+
logger.debug(f"Processing start message with conversation_id={conversation_id}")
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
# Create a new session
|
|
257
|
+
logger.debug(f"Creating session for {project_name}")
|
|
258
|
+
session = await create_session(
|
|
259
|
+
project_name,
|
|
260
|
+
project_dir,
|
|
261
|
+
conversation_id=conversation_id,
|
|
262
|
+
)
|
|
263
|
+
logger.debug("Session created, starting...")
|
|
264
|
+
|
|
265
|
+
# Stream the initial greeting
|
|
266
|
+
async for chunk in session.start():
|
|
267
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
268
|
+
logger.debug(f"Sending chunk: {chunk.get('type')}")
|
|
269
|
+
await websocket.send_json(chunk)
|
|
270
|
+
logger.debug("Session start complete")
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.exception(f"Error starting assistant session for {project_name}")
|
|
273
|
+
await websocket.send_json({
|
|
274
|
+
"type": "error",
|
|
275
|
+
"content": f"Failed to start session: {str(e)}"
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
elif msg_type == "message":
|
|
279
|
+
if not session:
|
|
280
|
+
session = get_session(project_name)
|
|
281
|
+
if not session:
|
|
282
|
+
await websocket.send_json({
|
|
283
|
+
"type": "error",
|
|
284
|
+
"content": "No active session. Send 'start' first."
|
|
285
|
+
})
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
user_content = message.get("content", "").strip()
|
|
289
|
+
if not user_content:
|
|
290
|
+
await websocket.send_json({
|
|
291
|
+
"type": "error",
|
|
292
|
+
"content": "Empty message"
|
|
293
|
+
})
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# Stream Claude's response
|
|
297
|
+
async for chunk in session.send_message(user_content):
|
|
298
|
+
await websocket.send_json(chunk)
|
|
299
|
+
|
|
300
|
+
else:
|
|
301
|
+
await websocket.send_json({
|
|
302
|
+
"type": "error",
|
|
303
|
+
"content": f"Unknown message type: {msg_type}"
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
await websocket.send_json({
|
|
308
|
+
"type": "error",
|
|
309
|
+
"content": "Invalid JSON"
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
except WebSocketDisconnect:
|
|
313
|
+
logger.info(f"Assistant chat WebSocket disconnected for {project_name}")
|
|
314
|
+
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.exception(f"Assistant chat WebSocket error for {project_name}")
|
|
317
|
+
try:
|
|
318
|
+
await websocket.send_json({
|
|
319
|
+
"type": "error",
|
|
320
|
+
"content": f"Server error: {str(e)}"
|
|
321
|
+
})
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
finally:
|
|
326
|
+
# Don't remove session on disconnect - allow resume
|
|
327
|
+
pass
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dev Server Router
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
API endpoints for dev server control (start/stop) and configuration.
|
|
6
|
+
Uses project registry for path lookups and project_config for command detection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
|
|
15
|
+
from ..schemas import (
|
|
16
|
+
DevServerActionResponse,
|
|
17
|
+
DevServerConfigResponse,
|
|
18
|
+
DevServerConfigUpdate,
|
|
19
|
+
DevServerStartRequest,
|
|
20
|
+
DevServerStatus,
|
|
21
|
+
)
|
|
22
|
+
from ..services.dev_server_manager import get_devserver_manager
|
|
23
|
+
from ..services.project_config import (
|
|
24
|
+
clear_dev_command,
|
|
25
|
+
get_dev_command,
|
|
26
|
+
get_project_config,
|
|
27
|
+
set_dev_command,
|
|
28
|
+
)
|
|
29
|
+
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
30
|
+
from ..utils.validation import validate_project_name
|
|
31
|
+
|
|
32
|
+
# Add root to path for security module import
|
|
33
|
+
_root = Path(__file__).parent.parent.parent
|
|
34
|
+
if str(_root) not in sys.path:
|
|
35
|
+
sys.path.insert(0, str(_root))
|
|
36
|
+
|
|
37
|
+
from security import extract_commands, get_effective_commands, is_command_allowed
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_project_dir(project_name: str) -> Path:
|
|
46
|
+
"""
|
|
47
|
+
Get the validated project directory for a project name.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
project_name: Name of the project
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to the project directory
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
HTTPException: If project is not found or directory does not exist
|
|
57
|
+
"""
|
|
58
|
+
project_name = validate_project_name(project_name)
|
|
59
|
+
project_dir = _get_project_path(project_name)
|
|
60
|
+
|
|
61
|
+
if not project_dir:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=404,
|
|
64
|
+
detail=f"Project '{project_name}' not found in registry"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not project_dir.exists():
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=404,
|
|
70
|
+
detail=f"Project directory not found: {project_dir}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return project_dir
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_project_devserver_manager(project_name: str):
|
|
77
|
+
"""
|
|
78
|
+
Get the dev server process manager for a project.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
project_name: Name of the project
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
DevServerProcessManager instance for the project
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
HTTPException: If project is not found or directory does not exist
|
|
88
|
+
"""
|
|
89
|
+
project_dir = get_project_dir(project_name)
|
|
90
|
+
return get_devserver_manager(project_name, project_dir)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_dev_command(command: str, project_dir: Path) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Validate a dev server command against the security allowlist.
|
|
96
|
+
|
|
97
|
+
Extracts all commands from the shell string and checks each against
|
|
98
|
+
the effective allowlist (global + org + project). Raises HTTPException
|
|
99
|
+
if any command is blocked or not allowed.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
command: The shell command string to validate
|
|
103
|
+
project_dir: Project directory for loading project-level allowlists
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
HTTPException 400: If the command fails validation
|
|
107
|
+
"""
|
|
108
|
+
commands = extract_commands(command)
|
|
109
|
+
if not commands:
|
|
110
|
+
raise HTTPException(
|
|
111
|
+
status_code=400,
|
|
112
|
+
detail="Could not parse command for security validation"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
allowed_commands, blocked_commands = get_effective_commands(project_dir)
|
|
116
|
+
|
|
117
|
+
for cmd in commands:
|
|
118
|
+
if cmd in blocked_commands:
|
|
119
|
+
logger.warning("Blocked dev server command '%s' (in blocklist) for project dir %s", cmd, project_dir)
|
|
120
|
+
raise HTTPException(
|
|
121
|
+
status_code=400,
|
|
122
|
+
detail=f"Command '{cmd}' is blocked and cannot be used as a dev server command"
|
|
123
|
+
)
|
|
124
|
+
if not is_command_allowed(cmd, allowed_commands):
|
|
125
|
+
logger.warning("Rejected dev server command '%s' (not in allowlist) for project dir %s", cmd, project_dir)
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=400,
|
|
128
|
+
detail=f"Command '{cmd}' is not in the allowed commands list"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ============================================================================
|
|
133
|
+
# Endpoints
|
|
134
|
+
# ============================================================================
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/status", response_model=DevServerStatus)
|
|
138
|
+
async def get_devserver_status(project_name: str) -> DevServerStatus:
|
|
139
|
+
"""
|
|
140
|
+
Get the current status of the dev server for a project.
|
|
141
|
+
|
|
142
|
+
Returns information about whether the dev server is running,
|
|
143
|
+
its process ID, detected URL, and the command used to start it.
|
|
144
|
+
"""
|
|
145
|
+
manager = get_project_devserver_manager(project_name)
|
|
146
|
+
|
|
147
|
+
# Run healthcheck to detect crashed processes
|
|
148
|
+
await manager.healthcheck()
|
|
149
|
+
|
|
150
|
+
return DevServerStatus(
|
|
151
|
+
status=manager.status,
|
|
152
|
+
pid=manager.pid,
|
|
153
|
+
url=manager.detected_url,
|
|
154
|
+
command=manager._command,
|
|
155
|
+
started_at=manager.started_at.isoformat() if manager.started_at else None,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.post("/start", response_model=DevServerActionResponse)
|
|
160
|
+
async def start_devserver(
|
|
161
|
+
project_name: str,
|
|
162
|
+
request: DevServerStartRequest = DevServerStartRequest(),
|
|
163
|
+
) -> DevServerActionResponse:
|
|
164
|
+
"""
|
|
165
|
+
Start the dev server for a project.
|
|
166
|
+
|
|
167
|
+
If a custom command is provided in the request, it will be used.
|
|
168
|
+
Otherwise, the effective command from the project configuration is used.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
project_name: Name of the project
|
|
172
|
+
request: Optional start request with custom command
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Response indicating success/failure and current status
|
|
176
|
+
"""
|
|
177
|
+
manager = get_project_devserver_manager(project_name)
|
|
178
|
+
project_dir = get_project_dir(project_name)
|
|
179
|
+
|
|
180
|
+
# Determine which command to use
|
|
181
|
+
command: str | None
|
|
182
|
+
if request.command:
|
|
183
|
+
command = request.command
|
|
184
|
+
else:
|
|
185
|
+
command = get_dev_command(project_dir)
|
|
186
|
+
|
|
187
|
+
if not command:
|
|
188
|
+
raise HTTPException(
|
|
189
|
+
status_code=400,
|
|
190
|
+
detail="No dev command available. Configure a custom command or ensure project type can be detected."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Validate command against security allowlist before execution
|
|
194
|
+
validate_dev_command(command, project_dir)
|
|
195
|
+
|
|
196
|
+
# Now command is definitely str and validated
|
|
197
|
+
success, message = await manager.start(command)
|
|
198
|
+
|
|
199
|
+
return DevServerActionResponse(
|
|
200
|
+
success=success,
|
|
201
|
+
status=manager.status,
|
|
202
|
+
message=message,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.post("/stop", response_model=DevServerActionResponse)
|
|
207
|
+
async def stop_devserver(project_name: str) -> DevServerActionResponse:
|
|
208
|
+
"""
|
|
209
|
+
Stop the dev server for a project.
|
|
210
|
+
|
|
211
|
+
Gracefully terminates the dev server process and all its child processes.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
project_name: Name of the project
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Response indicating success/failure and current status
|
|
218
|
+
"""
|
|
219
|
+
manager = get_project_devserver_manager(project_name)
|
|
220
|
+
|
|
221
|
+
success, message = await manager.stop()
|
|
222
|
+
|
|
223
|
+
return DevServerActionResponse(
|
|
224
|
+
success=success,
|
|
225
|
+
status=manager.status,
|
|
226
|
+
message=message,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.get("/config", response_model=DevServerConfigResponse)
|
|
231
|
+
async def get_devserver_config(project_name: str) -> DevServerConfigResponse:
|
|
232
|
+
"""
|
|
233
|
+
Get the dev server configuration for a project.
|
|
234
|
+
|
|
235
|
+
Returns information about:
|
|
236
|
+
- detected_type: The auto-detected project type (nodejs-vite, python-django, etc.)
|
|
237
|
+
- detected_command: The default command for the detected type
|
|
238
|
+
- custom_command: Any user-configured custom command
|
|
239
|
+
- effective_command: The command that will actually be used (custom or detected)
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
project_name: Name of the project
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Configuration details for the project's dev server
|
|
246
|
+
"""
|
|
247
|
+
project_dir = get_project_dir(project_name)
|
|
248
|
+
config = get_project_config(project_dir)
|
|
249
|
+
|
|
250
|
+
return DevServerConfigResponse(
|
|
251
|
+
detected_type=config["detected_type"],
|
|
252
|
+
detected_command=config["detected_command"],
|
|
253
|
+
custom_command=config["custom_command"],
|
|
254
|
+
effective_command=config["effective_command"],
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.patch("/config", response_model=DevServerConfigResponse)
|
|
259
|
+
async def update_devserver_config(
|
|
260
|
+
project_name: str,
|
|
261
|
+
update: DevServerConfigUpdate,
|
|
262
|
+
) -> DevServerConfigResponse:
|
|
263
|
+
"""
|
|
264
|
+
Update the dev server configuration for a project.
|
|
265
|
+
|
|
266
|
+
Set custom_command to a string to override the auto-detected command.
|
|
267
|
+
Set custom_command to null/None to clear the custom command and revert
|
|
268
|
+
to using the auto-detected command.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
project_name: Name of the project
|
|
272
|
+
update: Configuration update containing the new custom_command
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Updated configuration details for the project's dev server
|
|
276
|
+
"""
|
|
277
|
+
project_dir = get_project_dir(project_name)
|
|
278
|
+
|
|
279
|
+
# Update the custom command
|
|
280
|
+
if update.custom_command is None:
|
|
281
|
+
# Clear the custom command
|
|
282
|
+
try:
|
|
283
|
+
clear_dev_command(project_dir)
|
|
284
|
+
except ValueError as e:
|
|
285
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
286
|
+
else:
|
|
287
|
+
# Validate command against security allowlist before persisting
|
|
288
|
+
validate_dev_command(update.custom_command, project_dir)
|
|
289
|
+
|
|
290
|
+
# Set the custom command
|
|
291
|
+
try:
|
|
292
|
+
set_dev_command(project_dir, update.custom_command)
|
|
293
|
+
except ValueError as e:
|
|
294
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
295
|
+
except OSError as e:
|
|
296
|
+
raise HTTPException(
|
|
297
|
+
status_code=500,
|
|
298
|
+
detail=f"Failed to save configuration: {e}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Return updated config
|
|
302
|
+
config = get_project_config(project_dir)
|
|
303
|
+
|
|
304
|
+
return DevServerConfigResponse(
|
|
305
|
+
detected_type=config["detected_type"],
|
|
306
|
+
detected_command=config["detected_command"],
|
|
307
|
+
custom_command=config["custom_command"],
|
|
308
|
+
effective_command=config["effective_command"],
|
|
309
|
+
)
|