Skip to content

Commit df23a97

Browse files
authored
Merge pull request #213 from AutoForgeAI/feat/scaffold-template-selection
feat: add scaffold router and project template selection
2 parents 472064c + 41c1a14 commit df23a97

4 files changed

Lines changed: 355 additions & 9 deletions

File tree

server/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
features_router,
3737
filesystem_router,
3838
projects_router,
39+
scaffold_router,
3940
schedules_router,
4041
settings_router,
4142
spec_creation_router,
@@ -169,6 +170,7 @@ async def require_localhost(request: Request, call_next):
169170
app.include_router(assistant_chat_router)
170171
app.include_router(settings_router)
171172
app.include_router(terminal_router)
173+
app.include_router(scaffold_router)
172174

173175

174176
# ============================================================================

server/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .features import router as features_router
1313
from .filesystem import router as filesystem_router
1414
from .projects import router as projects_router
15+
from .scaffold import router as scaffold_router
1516
from .schedules import router as schedules_router
1617
from .settings import router as settings_router
1718
from .spec_creation import router as spec_creation_router
@@ -29,4 +30,5 @@
2930
"assistant_chat_router",
3031
"settings_router",
3132
"terminal_router",
33+
"scaffold_router",
3234
]

server/routers/scaffold.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
Scaffold Router
3+
================
4+
5+
SSE streaming endpoint for running project scaffold commands.
6+
Supports templated project creation (e.g., Next.js agentic starter).
7+
"""
8+
9+
import asyncio
10+
import json
11+
import logging
12+
import shutil
13+
import subprocess
14+
import sys
15+
from pathlib import Path
16+
17+
from fastapi import APIRouter, Request
18+
from fastapi.responses import StreamingResponse
19+
from pydantic import BaseModel
20+
21+
from .filesystem import is_path_blocked
22+
23+
logger = logging.getLogger(__name__)
24+
25+
router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
26+
27+
# Hardcoded templates — no arbitrary commands allowed
28+
TEMPLATES: dict[str, list[str]] = {
29+
"agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
30+
}
31+
32+
33+
class ScaffoldRequest(BaseModel):
34+
template: str
35+
target_path: str
36+
37+
38+
def _sse_event(data: dict) -> str:
39+
"""Format a dict as an SSE data line."""
40+
return f"data: {json.dumps(data)}\n\n"
41+
42+
43+
async def _stream_scaffold(template: str, target_path: str, request: Request):
44+
"""Run the scaffold command and yield SSE events."""
45+
# Validate template
46+
if template not in TEMPLATES:
47+
yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
48+
return
49+
50+
# Validate path
51+
path = Path(target_path)
52+
try:
53+
path = path.resolve()
54+
except (OSError, ValueError) as e:
55+
yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
56+
return
57+
58+
if is_path_blocked(path):
59+
yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
60+
return
61+
62+
if not path.exists() or not path.is_dir():
63+
yield _sse_event({"type": "error", "message": "Target directory does not exist"})
64+
return
65+
66+
# Check npx is available
67+
npx_name = "npx"
68+
if sys.platform == "win32":
69+
npx_name = "npx.cmd"
70+
71+
if not shutil.which(npx_name):
72+
yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
73+
return
74+
75+
# Build command
76+
argv = list(TEMPLATES[template])
77+
if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
78+
argv[0] = argv[0] + ".cmd"
79+
80+
process = None
81+
try:
82+
popen_kwargs: dict = {
83+
"stdout": subprocess.PIPE,
84+
"stderr": subprocess.STDOUT,
85+
"stdin": subprocess.DEVNULL,
86+
"cwd": str(path),
87+
}
88+
if sys.platform == "win32":
89+
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
90+
91+
process = subprocess.Popen(argv, **popen_kwargs)
92+
logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
93+
94+
# Stream stdout lines
95+
assert process.stdout is not None
96+
for raw_line in iter(process.stdout.readline, b""):
97+
# Check if client disconnected
98+
if await request.is_disconnected():
99+
logger.info("Client disconnected during scaffold, terminating process")
100+
break
101+
102+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
103+
yield _sse_event({"type": "output", "line": line})
104+
# Yield control to event loop so disconnect checks work
105+
await asyncio.sleep(0)
106+
107+
process.wait()
108+
exit_code = process.returncode
109+
success = exit_code == 0
110+
logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
111+
yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
112+
113+
except Exception as e:
114+
logger.error("Scaffold error: %s", e)
115+
yield _sse_event({"type": "error", "message": str(e)})
116+
117+
finally:
118+
if process and process.poll() is None:
119+
try:
120+
process.terminate()
121+
process.wait(timeout=5)
122+
except Exception:
123+
process.kill()
124+
125+
126+
@router.post("/run")
127+
async def run_scaffold(body: ScaffoldRequest, request: Request):
128+
"""Run a scaffold template command with SSE streaming output."""
129+
return StreamingResponse(
130+
_stream_scaffold(body.template, body.target_path, request),
131+
media_type="text/event-stream",
132+
headers={
133+
"Cache-Control": "no-cache",
134+
"X-Accel-Buffering": "no",
135+
},
136+
)

0 commit comments

Comments
 (0)